Skip to content

feat: +pages for programmatically defining pages (closes #1691) - #3356

Merged
brillout merged 44 commits into
mainfrom
claude/festive-ritchie-le2idx
Jun 21, 2026
Merged

feat: +pages for programmatically defining pages (closes #1691)#3356
brillout merged 44 commits into
mainfrom
claude/festive-ritchie-le2idx

Conversation

@brillout

@brillout brillout commented Jun 21, 2026

Copy link
Copy Markdown
Member

What

Implements the config-time half of #1691: define pages from code, without a +Page/+config file on the filesystem.

// pages/+config.js
import ProductPage from './ProductPage'
import LoginPage from './LoginPage'

export default {
  pages: [
    { route: '/product/@id', Page: ProductPage },
    { route: '/login',        Page: LoginPage },
  ],
}

This unblocks the "Single Route File" request (#341), is the foundation for extension-defined pages (e.g. vike-authjs/vike-better-auth), and is the registration primitive needed for the page-variant use cases in #1525 (OG images, .md for LLMs).

Note

Per discussion, this is only the list form. The function/transformer form (pages: (pages) => [...]) is intentionally left for a follow-up. Runtime/post-build programmatic pages are also out of scope.

How

Each pages entry is turned, at config-time, into a synthetic +config.js at a namespaced locationId{definingLocationId}/(programmatic)/{slug} — so it flows through the existing config-resolution pipeline unchanged, with no duplicated logic:

  • The entry { route, Page, … } is structurally a +config.js default export, so it reuses getPlusFileFromConfigFile(). This means Page is a top-level pointer import and gets resolved + serialized into a real runtime import for free.
  • Nesting under the defining location makes the page inherit the surrounding config (renderer via extends, title, etc.).
  • The (programmatic) marker is a parenthesis route-group (already a first-class Vike concept): it's stripped from the Filesystem-Routing fallback and guarantees a synthetic page can never collide with — nor become the inheritance parent of — a real page.
  • slug is derived from id (optional) or the route string; duplicates are asserted with a clear message.

Touchpoints:

  • types/Config.tspages?: ConfigPageEntry[] + exported ConfigPageEntry type; registered as a built-in global config name.
  • metaBuiltIn.tspages: { env: { config: true }, global: true, cumulative: true } (config-time only; never shipped to runtime; definable globally or by extensions).
  • getPlusFilesByLocationId.ts / addProgrammaticPages.tsaddProgrammaticPages() runs right after the filesystem crawl.

Verification

  • tsc --noEmit clean for packages/vike and first-party playground files.
  • Unit suite green: 180/180 (vitest --project unit).
  • Added a programmatic page to test/playground (+ e2e test wired into testRun.ts) and verified end-to-end, browser-free:
    • Build/prerender: emits dist/.../programmatic.html with the rendered Programmatic Page heading, inherits the page title (title: 'Big Playground'), and the Page component is shipped in a client chunk (hydrates via the client-routing entry). pageId === /pages/(programmatic)/programmatic.
    • Dev: GET /programmatic → 200 with rendered + inherited content.

Follow-ups (not in this PR)

Closes part of #1691.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z

claude and others added 7 commits June 21, 2026 11:01
Allow defining pages from code, without a +Page/+config file on the
filesystem:

    // pages/+config.js
    import ProductPage from './ProductPage'
    export default {
      pages: [
        { route: '/product/@id', Page: ProductPage },
      ],
    }

Each entry is resolved at config-time into a synthetic +config.js at a
namespaced locationId (`<definingLocationId>/(programmatic)/<slug>`), so
it flows through the existing config resolution unchanged: config
inheritance (renderer, title, ...), pointer-import resolution of `Page`,
routing, serialization, SSR and prerendering all work as for filesystem
pages.

This is the config-time half of #1691 (the function/transformer form is
not included yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Move addProgrammaticPages() (and its slug/locationId helpers) out of
getPlusFilesByLocationId.ts into a standalone file. getPlusFilesByLocationId.ts
now exports getPlusFileFromConfigFile() and the PlusFileConfig type for reuse.
No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
@suleimansh

Copy link
Copy Markdown
Member

Small thing, the description says it exports PageConfigEntry, but the code
exports ConfigPageEntry. Worth aligning the two (and the upcoming docs) on one name.

Copy link
Copy Markdown
Member Author

Good catch — aligned the PR description with the code. The exported type is ConfigPageEntry (the rename from PageConfigEntry landed in a later commit); that's the canonical name to use for the upcoming docs too. Thanks!


Generated by Claude Code

brillout and others added 3 commits June 21, 2026 14:07
The `id` field is superfluous: the slug (used only to derive the synthetic
locationId) is derived from the `route` string, falling back to the array
index for non-string routes (e.g. Route Functions). Duplicate slugs are
still asserted, advising a unique `route`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Replace the synthetic-plusFile approach (which fabricated a filesystem
locationId per entry under a `(programmatic)` directory and injected it
into plusFilesByLocationId) with a proper model that decouples a page's
*identity* from a filesystem *location*:

- Each config.pages entry becomes a first-class PageConfigBuildTime built
  in getProgrammaticPageConfigs().
- Config inheritance is anchored at the +config.js that defines
  config.pages, so the page inherits its surrounding config (renderer via
  `extends`, title, ssr, …) — exactly what a page at that location would.
- The entry's own values (route, Page, …) take highest precedence (like a
  filesystem page's own + files) and attach to the page only, so they don't
  leak to sibling pages via inheritance.
- pageId is an identity (anchor location + per-location index), not a
  fabricated path; plusFilesByLocationId stays purely filesystem.

Deletes addProgrammaticPages.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
claude and others added 6 commits June 21, 2026 16:07
The first cut applied effects before layering the entry's own config values,
so an entry that set/overrode a config with a meta.effect wouldn't trigger
it (effects only saw the inherited values).

Fix by routing programmatic pages through the same pipeline as filesystem
pages: extract buildPageConfig() and make the entry the page's most-specific
source (a synthetic child location of the defining +config.js, never added to
plusFilesByLocationId). Now resolveConfigValueSources + applyEffects +
sortConfigValueSources + getComputed all see the entry exactly like a
filesystem page's own + files — so effects, computed values and precedence
are identical and correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…on config

getProgrammaticPageConfigs() built the entry's throwaway PlusFile with the
defining file's isExtensionConfig. When config.pages is defined by an extension
(isExtensionConfig=true), resolveConfigValueSources() → dedupeExtensions() does
assert(getExtensionName(plusFile)), which crashes because the synthetic entry
has no `name`. The entry is a concrete page definition, not a dedupe-able
extension config, so build it with isExtensionConfig=false (also the correct
precedence). Unblocks extension-defined pages (e.g. vike-authjs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…on unknown configs

- Discover config.pages via getConfVal(), so it works both as config.pages in
  +config.js and as a +pages.js value file (verified end-to-end).
- Forbid Route Functions for now: ConfigPageEntry.route is typed as `string` and
  asserted at runtime (also rejects a missing/non-string route).
- Warn on unknown configs set by a page entry (reuses isUnknownConfig), like Vike
  does for + files.
- Remove `| ImportStringList` from the `pages` type (doesn't apply here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
isGlobalLocation() determined "does a config here apply to every page?" only
from filesystem page-locations. Programmatic pages (config.pages) aren't in
plusFilesByLocationId, so an app with only programmatic pages made every
location vacuously global. Count locations defining config.pages too (their
programmatic pages are anchored there), so a config that doesn't cover them
isn't considered global. Kept separate from isDefiningPage() so
getPageConfigsBuildTime() doesn't build a bogus filesystem page at the anchor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z

Copy link
Copy Markdown
Member Author

config.pages — known limitations (first iteration)

For reviewers/users, the current scope and intentional limitations of programmatically defined pages:

  1. Config-time only. config.pages is resolved at config-time. The function/transformer form (pages: (pages) => [...]) and runtime/CMS-driven (post-build) pages are out of scope for now.
  2. Route Strings only. A page entry's route must be a string. Route Functions are rejected with an assertion for now (ConfigPageEntry.route is typed string).
  3. pages is a global config. Define it at a global location (e.g. /pages/+config.js or /+config.js, or via an extension). Defining it at a non-global/scoped location still works but emits the usual "global config at a non-global location" warning.
  4. Identity is positional. A page's pageId is …/(programmatic)/<index> (the entry's index, scoped per defining location). Reordering/inserting/removing entries changes the pageIds of later entries — and thus globalContext.pages keys and client chunk filenames. URLs and pre-rendered output are unaffected (they're route-based). There is intentionally no per-entry id.
  5. No ImportStringList for pages or route. pages must be an inline array (or a +pages.js value file); route must be an inline string.
  6. Config-time values via pointer import inside an entry (e.g. an imported value for a env: { config: true } config) aren't loaded at config-time, so config-time readers see them as absent. Inline config-time values and all runtime imports (Page, data, hooks, …) work normally. (The main case — route functions — is already covered by Inject CSS of server-side only imports #2.)
  7. +config.js Page resolution rules are unchanged. A Page (or other component) referenced by an entry must be a pointer import — i.e. a .jsx/.tsx/.vue file (or any non-plain-script). A plain .ts/.js component isn't a pointer import (same as filesystem pages).
  8. getVikeConfig().config.pages (build-time) reflects the raw entries (including internal import strings for Page); use getVikeConfig().pages (the resolved page map) for the resolved pages.

Everything else (config inheritance, meta.effect, computed values, precedence, routing, pre-rendering, client-side hydration, both config.pages and +pages.js forms, and extension-defined pages) goes through the same pipeline as filesystem pages.


Generated by Claude Code

claude and others added 19 commits June 21, 2026 20:42
…d extends in entries

- Forbid setting a global config (e.g. +onBeforeRoute) on a single page entry; it
  was silently dropped. (Checks `global === true` so conditionally-global configs
  like +prerender remain allowed per-page.)
- Validate the entry's route via assertRouteString() (leading slash, non-empty, …)
  instead of only asserting it's a string.
- Forbid `extends` in an entry (it was a silent no-op).
- Add a perf note: createPageConfig() re-resolves the inherited config per entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Make the comments explaining the programmatic-pages build both more
succinct and clearer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…fig.pages)

A `config.pages` entry can now set `+route` to a Route Function, as long as
it's a pointer import so that Vike can load it at runtime:

```js
// +config.js
import route from './route' with { type: 'vike:pointer' }
export default {
  pages: [{ route, Page }]
}
```

Route Strings keep being validated at config-time; a Route Function (pointer
import) is validated at runtime, just like a Route Function defined via a
`+route.js` file. An inlined function still errors, with a message pointing to
the pointer-import syntax.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Split the single generic check into two bespoke assertUsage() calls:
- An inlined Route Function points the user to the pointer-import syntax.
- A non-string +route is told to use a Route String.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
@brillout brillout changed the title feat: programmatically define pages via config.pages (#1691) feat: +pages programmatically define pages (fix #1691) Jun 21, 2026
@brillout brillout changed the title feat: +pages programmatically define pages (fix #1691) feat: +pages programmatically define pages (closes #1691) Jun 21, 2026
@brillout brillout changed the title feat: +pages programmatically define pages (closes #1691) feat: +pages for programmatically defining pages (closes #1691) Jun 21, 2026
@brillout
brillout merged commit f6ecfb2 into main Jun 21, 2026
25 checks passed
@brillout
brillout deleted the claude/festive-ritchie-le2idx branch June 21, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants