diff --git a/.gitignore b/.gitignore
index 6c9f1720dc3..d1682ba1790 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ artifacts
coverage
*.tgz
.wrangler
+**/.angular/
# tests
packages/router-generator/tests/**/*.gen.ts
diff --git a/e2e/angular-router-experimental/basic/angular.json b/e2e/angular-router-experimental/basic/angular.json
new file mode 100644
index 00000000000..fc33c3d880e
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/angular.json
@@ -0,0 +1,61 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "cli": {
+ "packageManager": "pnpm",
+ "analytics": false
+ },
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-router-basic": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular/build:application",
+ "options": {
+ "conditions": ["module", "style"],
+ "browser": "src/main.ts",
+ "tsConfig": "tsconfig.app.json",
+ "assets": [],
+ "styles": ["src/styles.css"]
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kB",
+ "maximumError": "1MB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular/build:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "angular-router-basic:build:production"
+ },
+ "development": {
+ "buildTarget": "angular-router-basic:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ }
+ }
+ }
+ }
+}
+
diff --git a/e2e/angular-router-experimental/basic/package.json b/e2e/angular-router-experimental/basic/package.json
new file mode 100644
index 00000000000..489eded5371
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "tanstack-router-e2e-angular-basic",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "ng serve",
+ "build": "ng build",
+ "preview": "ng serve --configuration production",
+ "test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@angular/common": "^21.0.4",
+ "@angular/core": "^21.0.4",
+ "@angular/platform-browser": "^21.0.4",
+ "@tanstack/angular-router-experimental": "workspace:^",
+ "@tanstack/angular-router-devtools": "workspace:^",
+ "redaxios": "^0.5.1",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "@angular/build": "^21.0.4",
+ "@angular/cli": "^21.0.4",
+ "@angular/compiler-cli": "^21.0.4",
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.18",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/node": "22.10.2",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "typescript": "~5.9.2"
+ }
+}
diff --git a/e2e/angular-router-experimental/basic/playwright.config.ts b/e2e/angular-router-experimental/basic/playwright.config.ts
new file mode 100644
index 00000000000..7a20030f87c
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/playwright.config.ts
@@ -0,0 +1,43 @@
+import { defineConfig, devices } from '@playwright/test'
+import {
+ getDummyServerPort,
+ getTestServerPort,
+} from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+
+ reporter: [['line']],
+
+ globalSetup: './tests/setup/global.setup.ts',
+ globalTeardown: './tests/setup/global.teardown.ts',
+
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL,
+ },
+
+ webServer: {
+ command: `NODE_ENV=test SERVER_PORT=${PORT} EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && pnpm preview --port ${PORT}`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
+
diff --git a/e2e/angular-router-experimental/basic/src/index.html b/e2e/angular-router-experimental/basic/src/index.html
new file mode 100644
index 00000000000..9fbcbb74529
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Angular Router E2E
+
+
+
+
+
+
+
diff --git a/e2e/angular-router-experimental/basic/src/main.ts b/e2e/angular-router-experimental/basic/src/main.ts
new file mode 100644
index 00000000000..112029054d7
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/src/main.ts
@@ -0,0 +1,587 @@
+import { Component, computed, signal } from '@angular/core'
+import { JsonPipe } from '@angular/common'
+import { bootstrapApplication } from '@angular/platform-browser'
+import {
+ Outlet,
+ Link,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ injectErrorState,
+ injectRouter,
+ injectRouterState,
+ redirect,
+} from '@tanstack/angular-router-experimental'
+import { TanStackRouterDevtools } from '@tanstack/angular-router-devtools'
+import { NotFoundError, fetchPost, fetchPosts } from './posts'
+import './styles.css'
+
+const rootRoute = createRootRoute({
+ component: () => RootComponent,
+ notFoundComponent: () => NotFoundComponent,
+})
+
+@Component({
+ selector: 'app-root-layout',
+ standalone: true,
+ imports: [Outlet, Link, TanStackRouterDevtools],
+ template: `
+
+ `,
+})
+class RootComponent {
+ routerState = injectRouterState()
+
+ isActive(path: string): boolean {
+ const currentPath = this.routerState().location.pathname
+ if (path === '/') {
+ return currentPath === path
+ }
+ return currentPath === path || currentPath.startsWith(path + '/')
+ }
+}
+
+@Component({
+ selector: 'app-not-found',
+ standalone: true,
+ imports: [Link],
+ template: `
+
+
This is the notFoundComponent configured on root route
+
Start Over
+
+ `,
+})
+class NotFoundComponent {}
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexComponent,
+})
+
+@Component({
+ selector: 'app-index',
+ standalone: true,
+ template: `
+
+
Welcome Home!
+
+ `,
+})
+class IndexComponent {}
+
+export const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'posts',
+ loader: () => fetchPosts(),
+}).lazy(() => import('./posts.lazy').then((d) => d.Route))
+
+const postsIndexRoute = createRoute({
+ getParentRoute: () => postsRoute,
+ path: '/',
+ component: () => PostsIndexComponent,
+})
+
+@Component({
+ selector: 'app-posts-index',
+ standalone: true,
+ template: `Select a post.
`,
+})
+class PostsIndexComponent {}
+
+const postRoute = createRoute({
+ getParentRoute: () => postsRoute,
+ path: '$postId',
+ errorComponent: () => PostErrorComponent,
+ loader: ({ params }) => fetchPost(params.postId),
+ component: () => PostComponent,
+})
+
+@Component({
+ selector: 'app-post-error',
+ standalone: true,
+ template: `
+ @if (isNotFoundError()) {
+ {{ errorState.error.message }}
+ } @else {
+
+ }
+ `,
+})
+class PostErrorComponent {
+ errorState = injectErrorState()
+ isNotFoundError = computed(
+ () => this.errorState.error instanceof NotFoundError,
+ )
+}
+
+@Component({
+ selector: 'app-post',
+ standalone: true,
+ template: `
+
+
{{ post().title }}
+
+
{{ post().body }}
+
+ `,
+})
+class PostComponent {
+ post = postRoute.injectLoaderData()
+}
+
+const layoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: '_layout',
+ component: () => LayoutComponent,
+})
+
+@Component({
+ selector: 'app-layout',
+ standalone: true,
+ imports: [Outlet],
+ template: `
+
+ `,
+})
+class LayoutComponent {}
+
+const layout2Route = createRoute({
+ getParentRoute: () => layoutRoute,
+ id: '_layout-2',
+ component: () => Layout2Component,
+})
+
+@Component({
+ selector: 'app-layout-2',
+ standalone: true,
+ imports: [Outlet, Link],
+ template: `
+
+
I'm a nested layout
+
+
+
+
+
+ `,
+})
+class Layout2Component {
+ routerState = injectRouterState()
+
+ isActive(path: string): boolean {
+ const currentPath = this.routerState().location.pathname
+ return currentPath === path || currentPath.startsWith(path + '/')
+ }
+}
+
+const layoutARoute = createRoute({
+ getParentRoute: () => layout2Route,
+ path: '/layout-a',
+ component: () => LayoutAComponent,
+})
+
+@Component({
+ selector: 'app-layout-a',
+ standalone: true,
+ template: `I'm layout A!
`,
+})
+class LayoutAComponent {}
+
+const layoutBRoute = createRoute({
+ getParentRoute: () => layout2Route,
+ path: '/layout-b',
+ component: () => LayoutBComponent,
+})
+
+@Component({
+ selector: 'app-layout-b',
+ standalone: true,
+ template: `I'm layout B!
`,
+})
+class LayoutBComponent {}
+
+const paramsPsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/params-ps',
+})
+
+const paramsPsIndexRoute = createRoute({
+ getParentRoute: () => paramsPsRoute,
+ path: '/',
+ component: () => ParamsIndexComponent,
+})
+
+@Component({
+ selector: 'app-params-index',
+ standalone: true,
+ imports: [Link],
+ template: `
+
+
Named path params
+
+
+
Wildcard path params
+
+
+ `,
+})
+class ParamsIndexComponent {}
+
+const paramsPsNamedRoute = createRoute({
+ getParentRoute: () => paramsPsRoute,
+ path: '/named',
+})
+
+const paramsPsNamedIndexRoute = createRoute({
+ getParentRoute: () => paramsPsNamedRoute,
+ path: '/',
+ beforeLoad: () => {
+ throw redirect({ to: '/params-ps' })
+ },
+})
+
+const paramsPsNamedFooRoute = createRoute({
+ getParentRoute: () => paramsPsNamedRoute,
+ path: '/$foo',
+ component: () => ParamsNamedFooComponent,
+})
+
+@Component({
+ selector: 'app-params-named-foo',
+ standalone: true,
+ template: `
+
+
ParamsNamedFoo
+
{{ paramsJson() }}
+
+ `,
+})
+class ParamsNamedFooComponent {
+ params = paramsPsNamedFooRoute.injectParams()
+ paramsJson = computed(() => JSON.stringify(this.params()))
+}
+
+const paramsPsNamedFooPrefixRoute = createRoute({
+ getParentRoute: () => paramsPsNamedRoute,
+ path: '/prefix{$foo}',
+ component: () => ParamsNamedFooPrefixComponent,
+})
+
+@Component({
+ selector: 'app-params-named-foo-prefix',
+ standalone: true,
+ template: `
+
+
ParamsNamedFooPrefix
+
{{ paramsJson() }}
+
+ `,
+})
+class ParamsNamedFooPrefixComponent {
+ params = paramsPsNamedFooPrefixRoute.injectParams()
+ paramsJson = computed(() => JSON.stringify(this.params()))
+}
+
+const paramsPsNamedFooSuffixRoute = createRoute({
+ getParentRoute: () => paramsPsNamedRoute,
+ path: '/{$foo}suffix',
+ component: () => ParamsNamedFooSuffixComponent,
+})
+
+@Component({
+ selector: 'app-params-named-foo-suffix',
+ standalone: true,
+ template: `
+
+
ParamsNamedFooSuffix
+
{{ paramsJson() }}
+
+ `,
+})
+class ParamsNamedFooSuffixComponent {
+ params = paramsPsNamedFooSuffixRoute.injectParams()
+ paramsJson = computed(() => JSON.stringify(this.params()))
+}
+
+const paramsPsWildcardRoute = createRoute({
+ getParentRoute: () => paramsPsRoute,
+ path: '/wildcard',
+})
+
+const paramsPsWildcardIndexRoute = createRoute({
+ getParentRoute: () => paramsPsWildcardRoute,
+ path: '/',
+ beforeLoad: () => {
+ throw redirect({ to: '/params-ps' })
+ },
+})
+
+const paramsPsWildcardSplatRoute = createRoute({
+ getParentRoute: () => paramsPsWildcardRoute,
+ path: '$',
+ component: () => ParamsWildcardSplatComponent,
+})
+
+@Component({
+ selector: 'app-params-wildcard-splat',
+ standalone: true,
+ template: `
+
+
ParamsWildcardSplat
+
{{ paramsJson() }}
+
+ `,
+})
+class ParamsWildcardSplatComponent {
+ params = paramsPsWildcardSplatRoute.injectParams()
+ paramsJson = computed(() => JSON.stringify(this.params()))
+}
+
+const paramsPsWildcardSplatPrefixRoute = createRoute({
+ getParentRoute: () => paramsPsWildcardRoute,
+ path: 'prefix{$}',
+ component: () => ParamsWildcardSplatPrefixComponent,
+})
+
+@Component({
+ selector: 'app-params-wildcard-splat-prefix',
+ standalone: true,
+ template: `
+
+
ParamsWildcardSplatPrefix
+
{{ paramsJson() }}
+
+ `,
+})
+class ParamsWildcardSplatPrefixComponent {
+ params = paramsPsWildcardSplatPrefixRoute.injectParams()
+ paramsJson = computed(() => JSON.stringify(this.params()))
+}
+
+const paramsPsWildcardSplatSuffixRoute = createRoute({
+ getParentRoute: () => paramsPsWildcardRoute,
+ path: '{$}suffix',
+ component: () => ParamsWildcardSplatSuffixComponent,
+})
+
+@Component({
+ selector: 'app-params-wildcard-splat-suffix',
+ standalone: true,
+ template: `
+
+
ParamsWildcardSplatSuffix
+
{{ paramsJson() }}
+
+ `,
+})
+class ParamsWildcardSplatSuffixComponent {
+ params = paramsPsWildcardSplatSuffixRoute.injectParams()
+ paramsJson = computed(() => JSON.stringify(this.params()))
+}
+
+@Component({
+ selector: 'app-error',
+ standalone: true,
+ imports: [JsonPipe],
+ template: `
+
+
Error
+
{{ errorState.error | json }}
+
Reset
+
+ `,
+})
+class ErrorComponent {
+ errorState = injectErrorState()
+ router = injectRouter()
+
+ reset() {
+ this.router.invalidate()
+ }
+}
+
+const routeTree = rootRoute.addChildren([
+ postsRoute.addChildren([postRoute, postsIndexRoute]),
+ layoutRoute.addChildren([
+ layout2Route.addChildren([layoutARoute, layoutBRoute]),
+ ]),
+ paramsPsRoute.addChildren([
+ paramsPsNamedRoute.addChildren([
+ paramsPsNamedFooPrefixRoute,
+ paramsPsNamedFooSuffixRoute,
+ paramsPsNamedFooRoute,
+ paramsPsNamedIndexRoute,
+ ]),
+ paramsPsWildcardRoute.addChildren([
+ paramsPsWildcardSplatRoute,
+ paramsPsWildcardSplatPrefixRoute,
+ paramsPsWildcardSplatSuffixRoute,
+ paramsPsWildcardIndexRoute,
+ ]),
+ paramsPsIndexRoute,
+ ]),
+ indexRoute,
+])
+
+// Set up a Router instance
+const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultStaleTime: 5000,
+ scrollRestoration: true,
+ defaultErrorComponent: () => ErrorComponent,
+})
+
+// Register things for typesafety
+declare module '@tanstack/angular-router-experimental' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [RouterProvider],
+ template: ` `,
+})
+class AppComponent {
+ router = router
+}
+
+bootstrapApplication(AppComponent).catch((err) => console.error(err))
diff --git a/e2e/angular-router-experimental/basic/src/posts.lazy.ts b/e2e/angular-router-experimental/basic/src/posts.lazy.ts
new file mode 100644
index 00000000000..a42d3e0a7f3
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/src/posts.lazy.ts
@@ -0,0 +1,39 @@
+import { Component, computed } from '@angular/core'
+import { Outlet, Link, createLazyRoute } from '@tanstack/angular-router-experimental'
+
+export const Route = createLazyRoute('/posts')({
+ component: () => PostsComponent,
+})
+
+@Component({
+ selector: 'app-posts',
+ standalone: true,
+ imports: [Outlet, Link],
+ template: `
+
+ `,
+})
+class PostsComponent {
+ posts = Route.injectLoaderData()
+ postsWithExtra = computed(() => [
+ ...this.posts(),
+ { id: 'i-do-not-exist', title: 'Non-existent Post' },
+ ])
+}
diff --git a/e2e/angular-router-experimental/basic/src/posts.ts b/e2e/angular-router-experimental/basic/src/posts.ts
new file mode 100644
index 00000000000..5a339f29cf5
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/src/posts.ts
@@ -0,0 +1,39 @@
+import axios from 'redaxios'
+
+export class NotFoundError extends Error {}
+
+type PostType = {
+ id: string
+ title: string
+ body: string
+}
+
+let queryURL = 'https://jsonplaceholder.typicode.com'
+
+if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
+ const externalPort = process.env.EXTERNAL_PORT
+ if (externalPort) {
+ queryURL = `http://localhost:${externalPort}`
+ }
+}
+
+export const fetchPosts = async () => {
+ console.info('Fetching posts...')
+ return axios
+ .get>(`${queryURL}/posts`)
+ .then((r) => r.data.slice(0, 10))
+}
+
+export const fetchPost = async (postId: string) => {
+ console.info(`Fetching post with id ${postId}...`)
+ const post = await axios
+ .get(`${queryURL}/posts/${postId}`)
+ .then((r) => r.data)
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!post) {
+ throw new NotFoundError(`Post with id "${postId}" not found!`)
+ }
+
+ return post
+}
diff --git a/e2e/angular-router-experimental/basic/src/styles.css b/e2e/angular-router-experimental/basic/src/styles.css
new file mode 100644
index 00000000000..ecf40776164
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/src/styles.css
@@ -0,0 +1,22 @@
+@import 'tailwindcss';
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+html {
+ color-scheme: light dark;
+}
+* {
+ @apply border-gray-200 dark:border-gray-800;
+}
+body {
+ @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
+}
+
diff --git a/e2e/angular-router-experimental/basic/test-results/.last-run.json b/e2e/angular-router-experimental/basic/test-results/.last-run.json
new file mode 100644
index 00000000000..cbcc1fbac11
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/e2e/angular-router-experimental/basic/tests/app.spec.ts b/e2e/angular-router-experimental/basic/tests/app.spec.ts
new file mode 100644
index 00000000000..84f87952f47
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/tests/app.spec.ts
@@ -0,0 +1,52 @@
+import { expect, test } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from '../package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+})
+
+test('Navigating to a post page', async ({ page }) => {
+ await page.getByRole('link', { name: 'Posts', exact: true }).click()
+ await page.getByRole('link', { name: 'sunt aut facere repe' }).click()
+ await expect(page.getByRole('heading')).toContainText('sunt aut facere')
+})
+
+test('Navigating nested layouts', async ({ page }) => {
+ await page.getByRole('link', { name: 'Layout', exact: true }).click()
+
+ await expect(page.locator('#app')).toContainText("I'm a layout")
+ await expect(page.locator('#app')).toContainText("I'm a nested layout")
+
+ await page.getByRole('link', { name: 'Layout A' }).click()
+ await expect(page.locator('#app')).toContainText("I'm layout A!")
+
+ await page.getByRole('link', { name: 'Layout B' }).click()
+ await expect(page.locator('#app')).toContainText("I'm layout B!")
+})
+
+test('Navigating to a not-found route', async ({ page }) => {
+ await page.getByRole('link', { name: 'This Route Does Not Exist' }).click()
+ await expect(page.getByRole('paragraph')).toContainText(
+ 'This is the notFoundComponent configured on root route',
+ )
+ await page.getByRole('link', { name: 'Start Over' }).click()
+ await expect(page.getByRole('heading')).toContainText('Welcome Home!')
+})
+
+test('Navigating to a post page with viewTransition', async ({ page }) => {
+ await page.getByRole('link', { name: 'View Transition', exact: true }).click()
+ await page.getByRole('link', { name: 'sunt aut facere repe' }).click()
+ await expect(page.getByRole('heading')).toContainText('sunt aut facere')
+})
+
+test('Navigating to a post page with viewTransition types', async ({
+ page,
+}) => {
+ await page.getByRole('link', { name: 'View Transition types' }).click()
+ await page.getByRole('link', { name: 'sunt aut facere repe' }).click()
+ await expect(page.getByRole('heading')).toContainText('sunt aut facere')
+})
+
diff --git a/e2e/angular-router-experimental/basic/tests/params.spec.ts b/e2e/angular-router-experimental/basic/tests/params.spec.ts
new file mode 100644
index 00000000000..5270ca92a1f
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/tests/params.spec.ts
@@ -0,0 +1,150 @@
+import { expect, test } from '@playwright/test'
+
+test.describe('params operations + prefix/suffix', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/params-ps')
+ })
+
+ test.describe('named params', () => {
+ const NAMED_PARAMS_PAIRS = [
+ // Test ID | Expected href
+ {
+ id: 'l-to-named-foo',
+ pathname: '/params-ps/named/foo',
+ params: { foo: 'foo' },
+ destHeadingId: 'ParamsNamedFoo',
+ },
+ {
+ id: 'l-to-named-prefixfoo',
+ pathname: '/params-ps/named/prefixfoo',
+ params: { foo: 'foo' },
+ destHeadingId: 'ParamsNamedFooPrefix',
+ },
+ {
+ id: 'l-to-named-foosuffix',
+ pathname: '/params-ps/named/foosuffix',
+ params: { foo: 'foo' },
+ destHeadingId: 'ParamsNamedFooSuffix',
+ },
+ ] satisfies Array<{
+ id: string
+ pathname: string
+ params: Record
+ destHeadingId: string
+ }>
+
+ test.describe('Link', () => {
+ NAMED_PARAMS_PAIRS.forEach(({ id, pathname }) => {
+ test(`interpolation for testid="${id}" has href="${pathname}"`, async ({
+ page,
+ }) => {
+ const link = page.getByTestId(id)
+ await expect(link).toHaveAttribute('href', pathname)
+ })
+ })
+
+ NAMED_PARAMS_PAIRS.forEach(({ id, pathname }) => {
+ test(`navigation for testid="${id}" succeeds to href="${pathname}"`, async ({
+ page,
+ }) => {
+ const link = page.getByTestId(id)
+ await link.click()
+ await page.waitForLoadState('networkidle')
+ const pagePathname = new URL(page.url()).pathname
+ expect(pagePathname).toBe(pathname)
+ })
+ })
+ })
+
+ NAMED_PARAMS_PAIRS.forEach(({ pathname, params, destHeadingId }) => {
+ test(`on first-load to "${pathname}" has correct params`, async ({
+ page,
+ }) => {
+ await page.goto(pathname)
+ await page.waitForLoadState('networkidle')
+ const pagePathname = new URL(page.url()).pathname
+ expect(pagePathname).toBe(pathname)
+
+ const headingEl = page.getByRole('heading', { name: destHeadingId })
+ await expect(headingEl).toBeVisible()
+
+ const paramsEl = page.getByTestId('params-output')
+ const paramsText = await paramsEl.innerText()
+ const paramsObj = JSON.parse(paramsText)
+ expect(paramsObj).toEqual(params)
+ })
+ })
+ })
+
+ test.describe('wildcard param', () => {
+ const WILDCARD_PARAM_PAIRS = [
+ // Test ID | Expected href
+ {
+ id: 'l-to-wildcard-foo',
+ pathname: '/params-ps/wildcard/foo',
+ params: { '*': 'foo', _splat: 'foo' },
+ destHeadingId: 'ParamsWildcardSplat',
+ },
+ {
+ id: 'l-to-wildcard-prefixfoo',
+ pathname: '/params-ps/wildcard/prefixfoo',
+ params: { '*': 'foo', _splat: 'foo' },
+ destHeadingId: 'ParamsWildcardSplatPrefix',
+ },
+ {
+ id: 'l-to-wildcard-foosuffix',
+ pathname: '/params-ps/wildcard/foosuffix',
+ params: { '*': 'foo', _splat: 'foo' },
+ destHeadingId: 'ParamsWildcardSplatSuffix',
+ },
+ ] satisfies Array<{
+ id: string
+ pathname: string
+ params: Record
+ destHeadingId: string
+ }>
+
+ test.describe('Link', () => {
+ WILDCARD_PARAM_PAIRS.forEach(({ id, pathname }) => {
+ test(`interpolation for testid="${id}" has href="${pathname}"`, async ({
+ page,
+ }) => {
+ const link = page.getByTestId(id)
+ await expect(link).toHaveAttribute('href', pathname)
+ })
+ })
+
+ WILDCARD_PARAM_PAIRS.forEach(({ id, pathname }) => {
+ test(`navigation for testid="${id}" succeeds to href="${pathname}"`, async ({
+ page,
+ }) => {
+ const link = page.getByTestId(id)
+ await link.click()
+ await page.waitForLoadState('networkidle')
+ const pagePathname = new URL(page.url()).pathname
+ expect(pagePathname).toBe(pathname)
+ })
+ })
+ })
+
+ WILDCARD_PARAM_PAIRS.forEach(({ pathname, params, destHeadingId }) => {
+ test(`on first-load to "${pathname}" has correct params`, async ({
+ page,
+ }) => {
+ await page.goto(pathname)
+ await page.waitForLoadState('networkidle')
+ const pagePathname = new URL(page.url()).pathname
+ expect(pagePathname).toBe(pathname)
+
+ const headingEl = page.getByRole('heading', { name: destHeadingId })
+ await expect(headingEl).toBeVisible()
+
+ const paramsEl = page.getByTestId('params-output')
+ const paramsText = await paramsEl.innerText()
+ const paramsObj = JSON.parse(paramsText)
+ expect(paramsObj).toEqual(params)
+ })
+ })
+ })
+})
+
diff --git a/e2e/angular-router-experimental/basic/tests/setup/global.setup.ts b/e2e/angular-router-experimental/basic/tests/setup/global.setup.ts
new file mode 100644
index 00000000000..0f402c20ea0
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/tests/setup/global.setup.ts
@@ -0,0 +1,7 @@
+import { e2eStartDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function setup() {
+ await e2eStartDummyServer(packageJson.name)
+}
+
diff --git a/e2e/angular-router-experimental/basic/tests/setup/global.teardown.ts b/e2e/angular-router-experimental/basic/tests/setup/global.teardown.ts
new file mode 100644
index 00000000000..68eef095381
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/tests/setup/global.teardown.ts
@@ -0,0 +1,7 @@
+import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function teardown() {
+ await e2eStopDummyServer(packageJson.name)
+}
+
diff --git a/e2e/angular-router-experimental/basic/tsconfig.app.json b/e2e/angular-router-experimental/basic/tsconfig.app.json
new file mode 100644
index 00000000000..1483d1e95bd
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/tsconfig.app.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": ["node"]
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.ts"]
+}
+
diff --git a/e2e/angular-router-experimental/basic/tsconfig.json b/e2e/angular-router-experimental/basic/tsconfig.json
new file mode 100644
index 00000000000..2328d57a844
--- /dev/null
+++ b/e2e/angular-router-experimental/basic/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM"],
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "types": ["@playwright/test", "node"]
+ },
+ "include": ["src/**/*", "tests/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
+
diff --git a/examples/angular/basic-file-based/README.md b/examples/angular/basic-file-based/README.md
new file mode 100644
index 00000000000..c1f2a48f73b
--- /dev/null
+++ b/examples/angular/basic-file-based/README.md
@@ -0,0 +1,42 @@
+# TanStack Router - Angular Basic File-based Example
+
+This example showcases **file-based route generation** for Angular using `@tanstack/angular-router-experimental` and `@tanstack/router-plugin` with Vite, aligned with the React basic-file-based example.
+
+## What's demonstrated
+
+- **Target: Angular** – The router generator is configured with `target: 'angular'` so route files use Angular standalone components and `@tanstack/angular-router-experimental'`.
+- **Autogenerated route files** – Adding new files under `src/routes/` (e.g. `__root.ts`, `index.ts`, `about.ts`, `posts.route.ts`) and running the dev server or generator will scaffold Angular components and route definitions.
+- **Generated route tree** – `src/routeTree.gen.ts` is produced by the plugin and wired into the app router.
+
+## Setup
+
+From the repo root:
+
+```bash
+pnpm install
+cd examples/angular/basic-file-based
+pnpm dev
+```
+
+Open http://localhost:3001.
+
+## Project structure
+
+- `src/routes/` – File-based routes. The generator creates/updates files here.
+- `src/routeTree.gen.ts` – Generated route tree (do not edit by hand).
+- `src/router.ts` – Router instance using the generated `routeTree`.
+- `vite.config.ts` – Plugin config: `tanstackRouter({ target: 'angular', ... })`.
+
+## Adding new routes
+
+1. Add a new file under `src/routes/`, e.g. `src/routes/contact.ts`.
+2. Run `pnpm dev` (or `pnpm generate`). The plugin will scaffold the route file with an Angular component and `createFileRoute(...)`.
+3. The route tree is updated automatically; navigate to `/contact`.
+
+## Generating routes manually
+
+```bash
+pnpm generate
+```
+
+This runs the TanStack Router generator (e.g. via `tsr generate` if the CLI is installed) and updates route files and `routeTree.gen.ts`.
diff --git a/examples/angular/basic-file-based/index.html b/examples/angular/basic-file-based/index.html
new file mode 100644
index 00000000000..6c4f87e3001
--- /dev/null
+++ b/examples/angular/basic-file-based/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ TanStack Router - Angular File-based
+
+
+
+
+
+
diff --git a/examples/angular/basic-file-based/package.json b/examples/angular/basic-file-based/package.json
new file mode 100644
index 00000000000..dcd695be171
--- /dev/null
+++ b/examples/angular/basic-file-based/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "tanstack-router-angular-example-basic-file-based",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3001",
+ "build": "vite build",
+ "preview": "vite preview",
+ "generate": "tsr generate"
+ },
+ "dependencies": {
+ "@angular/common": "21.0.4",
+ "@angular/compiler": "21.0.4",
+ "@angular/core": "21.0.4",
+ "@angular/platform-browser": "21.0.4",
+ "@tanstack/angular-router-experimental": "workspace:^",
+ "@tanstack/angular-router-devtools": "workspace:^",
+ "@tailwindcss/vite": "^4.1.18",
+ "rxjs": "~7.8.0",
+ "tailwindcss": "^4.1.18",
+ "tslib": "^2.3.0"
+ },
+ "devDependencies": {
+ "@analogjs/vite-plugin-angular": "^2.2.1",
+ "@tanstack/router-cli": "workspace:*",
+ "@tanstack/router-plugin": "workspace:*",
+ "typescript": "~5.9.2",
+ "vite": "^7.1.7"
+ }
+}
diff --git a/examples/angular/basic-file-based/src/app/app.config.ts b/examples/angular/basic-file-based/src/app/app.config.ts
new file mode 100644
index 00000000000..f27099f33c0
--- /dev/null
+++ b/examples/angular/basic-file-based/src/app/app.config.ts
@@ -0,0 +1,5 @@
+import { ApplicationConfig } from '@angular/core'
+
+export const appConfig: ApplicationConfig = {
+ providers: [],
+}
diff --git a/examples/angular/basic-file-based/src/app/app.ts b/examples/angular/basic-file-based/src/app/app.ts
new file mode 100644
index 00000000000..829f370d144
--- /dev/null
+++ b/examples/angular/basic-file-based/src/app/app.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core'
+import { RouterProvider } from '@tanstack/angular-router-experimental'
+import { router } from '../router'
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [RouterProvider],
+ template: ` `,
+})
+export class App {
+ router = router
+}
diff --git a/examples/angular/basic-file-based/src/main.ts b/examples/angular/basic-file-based/src/main.ts
new file mode 100644
index 00000000000..24b9d6e3b2d
--- /dev/null
+++ b/examples/angular/basic-file-based/src/main.ts
@@ -0,0 +1,6 @@
+import { bootstrapApplication } from '@angular/platform-browser'
+import { App } from './app/app'
+import { appConfig } from './app/app.config'
+import './styles.css'
+
+bootstrapApplication(App, appConfig).catch((err) => console.error(err))
diff --git a/examples/angular/basic-file-based/src/posts.ts b/examples/angular/basic-file-based/src/posts.ts
new file mode 100644
index 00000000000..42f0d995c63
--- /dev/null
+++ b/examples/angular/basic-file-based/src/posts.ts
@@ -0,0 +1,29 @@
+import { notFound } from '@tanstack/angular-router-experimental'
+
+export type PostType = {
+ id: string
+ title: string
+ body: string
+}
+
+export const fetchPost = async (postId: string) => {
+ console.info(`Fetching post with id ${postId}...`)
+ await new Promise((r) => setTimeout(r, 500))
+ const res = await fetch(
+ `https://jsonplaceholder.typicode.com/posts/${postId}`,
+ )
+ if (res.status === 404) {
+ throw notFound()
+ }
+ if (!res.ok) throw new Error(await res.text())
+ return res.json() as Promise
+}
+
+export const fetchPosts = async () => {
+ console.info('Fetching posts...')
+ await new Promise((r) => setTimeout(r, 500))
+ const res = await fetch('https://jsonplaceholder.typicode.com/posts')
+ if (!res.ok) throw new Error(await res.text())
+ const data = (await res.json()) as PostType[]
+ return data.slice(0, 10)
+}
diff --git a/examples/angular/basic-file-based/src/routeTree.gen.ts b/examples/angular/basic-file-based/src/routeTree.gen.ts
new file mode 100644
index 00000000000..625123d4574
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routeTree.gen.ts
@@ -0,0 +1,250 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as AnchorRouteImport } from './routes/anchor'
+import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
+import { Route as PostsRouteRouteImport } from './routes/posts.route'
+import { Route as IndexRouteImport } from './routes/index'
+import { Route as PostsIndexRouteImport } from './routes/posts.index'
+import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
+import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
+import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
+import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
+
+const AnchorRoute = AnchorRouteImport.update({
+ id: '/anchor',
+ path: '/anchor',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const PathlessLayoutRoute = PathlessLayoutRouteImport.update({
+ id: '/_pathlessLayout',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const PostsRouteRoute = PostsRouteRouteImport.update({
+ id: '/posts',
+ path: '/posts',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const PostsIndexRoute = PostsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => PostsRouteRoute,
+} as any)
+const PostsPostIdRoute = PostsPostIdRouteImport.update({
+ id: '/$postId',
+ path: '/$postId',
+ getParentRoute: () => PostsRouteRoute,
+} as any)
+const PathlessLayoutNestedLayoutRoute =
+ PathlessLayoutNestedLayoutRouteImport.update({
+ id: '/_nested-layout',
+ getParentRoute: () => PathlessLayoutRoute,
+ } as any)
+const PathlessLayoutNestedLayoutRouteBRoute =
+ PathlessLayoutNestedLayoutRouteBRouteImport.update({
+ id: '/route-b',
+ path: '/route-b',
+ getParentRoute: () => PathlessLayoutNestedLayoutRoute,
+ } as any)
+const PathlessLayoutNestedLayoutRouteARoute =
+ PathlessLayoutNestedLayoutRouteARouteImport.update({
+ id: '/route-a',
+ path: '/route-a',
+ getParentRoute: () => PathlessLayoutNestedLayoutRoute,
+ } as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/posts': typeof PostsRouteRouteWithChildren
+ '/anchor': typeof AnchorRoute
+ '/posts/$postId': typeof PostsPostIdRoute
+ '/posts/': typeof PostsIndexRoute
+ '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
+ '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/anchor': typeof AnchorRoute
+ '/posts/$postId': typeof PostsPostIdRoute
+ '/posts': typeof PostsIndexRoute
+ '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
+ '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/posts': typeof PostsRouteRouteWithChildren
+ '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
+ '/anchor': typeof AnchorRoute
+ '/_pathlessLayout/_nested-layout': typeof PathlessLayoutNestedLayoutRouteWithChildren
+ '/posts/$postId': typeof PostsPostIdRoute
+ '/posts/': typeof PostsIndexRoute
+ '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
+ '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/posts'
+ | '/anchor'
+ | '/posts/$postId'
+ | '/posts/'
+ | '/route-a'
+ | '/route-b'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/anchor' | '/posts/$postId' | '/posts' | '/route-a' | '/route-b'
+ id:
+ | '__root__'
+ | '/'
+ | '/posts'
+ | '/_pathlessLayout'
+ | '/anchor'
+ | '/_pathlessLayout/_nested-layout'
+ | '/posts/$postId'
+ | '/posts/'
+ | '/_pathlessLayout/_nested-layout/route-a'
+ | '/_pathlessLayout/_nested-layout/route-b'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ PostsRouteRoute: typeof PostsRouteRouteWithChildren
+ PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
+ AnchorRoute: typeof AnchorRoute
+}
+
+declare module '@tanstack/angular-router-experimental' {
+ interface FileRoutesByPath {
+ '/anchor': {
+ id: '/anchor'
+ path: '/anchor'
+ fullPath: '/anchor'
+ preLoaderRoute: typeof AnchorRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_pathlessLayout': {
+ id: '/_pathlessLayout'
+ path: ''
+ fullPath: ''
+ preLoaderRoute: typeof PathlessLayoutRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/posts': {
+ id: '/posts'
+ path: '/posts'
+ fullPath: '/posts'
+ preLoaderRoute: typeof PostsRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/posts/': {
+ id: '/posts/'
+ path: '/'
+ fullPath: '/posts/'
+ preLoaderRoute: typeof PostsIndexRouteImport
+ parentRoute: typeof PostsRouteRoute
+ }
+ '/posts/$postId': {
+ id: '/posts/$postId'
+ path: '/$postId'
+ fullPath: '/posts/$postId'
+ preLoaderRoute: typeof PostsPostIdRouteImport
+ parentRoute: typeof PostsRouteRoute
+ }
+ '/_pathlessLayout/_nested-layout': {
+ id: '/_pathlessLayout/_nested-layout'
+ path: ''
+ fullPath: ''
+ preLoaderRoute: typeof PathlessLayoutNestedLayoutRouteImport
+ parentRoute: typeof PathlessLayoutRoute
+ }
+ '/_pathlessLayout/_nested-layout/route-b': {
+ id: '/_pathlessLayout/_nested-layout/route-b'
+ path: '/route-b'
+ fullPath: '/route-b'
+ preLoaderRoute: typeof PathlessLayoutNestedLayoutRouteBRouteImport
+ parentRoute: typeof PathlessLayoutNestedLayoutRoute
+ }
+ '/_pathlessLayout/_nested-layout/route-a': {
+ id: '/_pathlessLayout/_nested-layout/route-a'
+ path: '/route-a'
+ fullPath: '/route-a'
+ preLoaderRoute: typeof PathlessLayoutNestedLayoutRouteARouteImport
+ parentRoute: typeof PathlessLayoutNestedLayoutRoute
+ }
+ }
+}
+
+interface PostsRouteRouteChildren {
+ PostsPostIdRoute: typeof PostsPostIdRoute
+ PostsIndexRoute: typeof PostsIndexRoute
+}
+
+const PostsRouteRouteChildren: PostsRouteRouteChildren = {
+ PostsPostIdRoute: PostsPostIdRoute,
+ PostsIndexRoute: PostsIndexRoute,
+}
+
+const PostsRouteRouteWithChildren = PostsRouteRoute._addFileChildren(
+ PostsRouteRouteChildren,
+)
+
+interface PathlessLayoutNestedLayoutRouteChildren {
+ PathlessLayoutNestedLayoutRouteARoute: typeof PathlessLayoutNestedLayoutRouteARoute
+ PathlessLayoutNestedLayoutRouteBRoute: typeof PathlessLayoutNestedLayoutRouteBRoute
+}
+
+const PathlessLayoutNestedLayoutRouteChildren: PathlessLayoutNestedLayoutRouteChildren =
+ {
+ PathlessLayoutNestedLayoutRouteARoute:
+ PathlessLayoutNestedLayoutRouteARoute,
+ PathlessLayoutNestedLayoutRouteBRoute:
+ PathlessLayoutNestedLayoutRouteBRoute,
+ }
+
+const PathlessLayoutNestedLayoutRouteWithChildren =
+ PathlessLayoutNestedLayoutRoute._addFileChildren(
+ PathlessLayoutNestedLayoutRouteChildren,
+ )
+
+interface PathlessLayoutRouteChildren {
+ PathlessLayoutNestedLayoutRoute: typeof PathlessLayoutNestedLayoutRouteWithChildren
+}
+
+const PathlessLayoutRouteChildren: PathlessLayoutRouteChildren = {
+ PathlessLayoutNestedLayoutRoute: PathlessLayoutNestedLayoutRouteWithChildren,
+}
+
+const PathlessLayoutRouteWithChildren = PathlessLayoutRoute._addFileChildren(
+ PathlessLayoutRouteChildren,
+)
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ PostsRouteRoute: PostsRouteRouteWithChildren,
+ PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
+ AnchorRoute: AnchorRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/examples/angular/basic-file-based/src/router.ts b/examples/angular/basic-file-based/src/router.ts
new file mode 100644
index 00000000000..f9b2da7b2ec
--- /dev/null
+++ b/examples/angular/basic-file-based/src/router.ts
@@ -0,0 +1,13 @@
+import { createRouter } from '@tanstack/angular-router-experimental'
+import { routeTree } from './routeTree.gen'
+
+export const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+})
+
+declare module '@tanstack/angular-router-experimental' {
+ interface Register {
+ router: typeof router
+ }
+}
diff --git a/examples/angular/basic-file-based/src/routes/__root.ts b/examples/angular/basic-file-based/src/routes/__root.ts
new file mode 100644
index 00000000000..46ea8da6fc9
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/__root.ts
@@ -0,0 +1,58 @@
+import { Component } from '@angular/core'
+import {
+ Link,
+ Outlet,
+ createRootRoute,
+} from '@tanstack/angular-router-experimental'
+import { TanStackRouterDevtools } from '@tanstack/angular-router-devtools'
+
+export const Route = createRootRoute({
+ component: () => RootComponent,
+ notFoundComponent: () => NotFoundComponent,
+})
+
+@Component({
+ selector: 'root-route',
+ standalone: true,
+ template: `
+
+
+
+
+ `,
+ imports: [Outlet, Link, TanStackRouterDevtools],
+})
+class RootComponent {}
+
+@Component({
+ selector: 'not-found',
+ standalone: true,
+ template: `
+
+
This is the notFoundComponent configured on root route
+
Start Over
+
+ `,
+ imports: [Link],
+})
+class NotFoundComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/_pathlessLayout.ts b/examples/angular/basic-file-based/src/routes/_pathlessLayout.ts
new file mode 100644
index 00000000000..157529d412e
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/_pathlessLayout.ts
@@ -0,0 +1,19 @@
+import { Component } from '@angular/core'
+import { createFileRoute, Outlet } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/_pathlessLayout')({
+ component: () => PathlessLayoutComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `
+
+
I'm a pathless layout
+
+
+ `,
+ imports: [Outlet],
+})
+class PathlessLayoutComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout.ts b/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout.ts
new file mode 100644
index 00000000000..faedf2e1c5c
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout.ts
@@ -0,0 +1,23 @@
+import { Component } from '@angular/core'
+import { createFileRoute, Link, Outlet } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/_pathlessLayout/_nested-layout')({
+ component: () => NestedLayoutComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `
+
+
I'm a nested pathless layout
+
+
+
+ `,
+ imports: [Link, Outlet],
+})
+class NestedLayoutComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout/route-a.ts b/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout/route-a.ts
new file mode 100644
index 00000000000..9dcc8fe57a6
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout/route-a.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core'
+import { createFileRoute } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/_pathlessLayout/_nested-layout/route-a')({
+ component: () => RouteAComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `I'm layout A!
`,
+})
+class RouteAComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout/route-b.ts b/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout/route-b.ts
new file mode 100644
index 00000000000..37e77920251
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/_pathlessLayout/_nested-layout/route-b.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core'
+import { createFileRoute } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/_pathlessLayout/_nested-layout/route-b')({
+ component: () => RouteBComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `I'm layout B!
`,
+})
+class RouteBComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/anchor.ts b/examples/angular/basic-file-based/src/routes/anchor.ts
new file mode 100644
index 00000000000..dd2ed2dde9c
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/anchor.ts
@@ -0,0 +1,60 @@
+import { Component } from '@angular/core'
+import { createFileRoute, Link } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/anchor')({
+ component: () => AnchorComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `
+
+
+
+
+
+
+
Default Anchor
+
+
+
No Scroll Into View
+
+
+
Smooth Scroll
+
+
+
+ `,
+ imports: [Link],
+})
+class AnchorComponent {
+ route = Route
+}
diff --git a/examples/angular/basic-file-based/src/routes/index.ts b/examples/angular/basic-file-based/src/routes/index.ts
new file mode 100644
index 00000000000..c6feb029e0a
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/index.ts
@@ -0,0 +1,17 @@
+import { Component } from '@angular/core'
+import { createFileRoute } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/')({
+ component: () => IndexComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `
+
+
Welcome Home!
+
+ `,
+})
+class IndexComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/posts.$postId.ts b/examples/angular/basic-file-based/src/routes/posts.$postId.ts
new file mode 100644
index 00000000000..b2f4eac0e41
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/posts.$postId.ts
@@ -0,0 +1,46 @@
+import { Component } from '@angular/core'
+import {
+ createFileRoute,
+ injectErrorState,
+} from '@tanstack/angular-router-experimental'
+import { fetchPost } from '../posts'
+
+export const Route = createFileRoute('/posts/$postId')({
+ loader: ({ params }) => fetchPost(params.postId),
+ errorComponent: () => PostErrorComponent,
+ notFoundComponent: () => PostNotFoundComponent,
+ component: () => PostComponent,
+})
+
+@Component({
+ selector: 'post-error',
+ standalone: true,
+ template: `
+ Error: {{ errorState().error.message }}
+ Reset
+ `,
+})
+class PostErrorComponent {
+ errorState = injectErrorState()
+}
+
+@Component({
+ selector: 'post-not-found',
+ standalone: true,
+ template: `Post not found
`,
+})
+class PostNotFoundComponent {}
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `
+
+
{{ post().title }}
+
{{ post().body }}
+
+ `,
+})
+class PostComponent {
+ post = Route.injectLoaderData()
+}
diff --git a/examples/angular/basic-file-based/src/routes/posts.index.ts b/examples/angular/basic-file-based/src/routes/posts.index.ts
new file mode 100644
index 00000000000..d042a16895b
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/posts.index.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core'
+import { createFileRoute } from '@tanstack/angular-router-experimental'
+
+export const Route = createFileRoute('/posts/')({
+ component: () => PostsIndexComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `Select a post.
`,
+})
+class PostsIndexComponent {}
diff --git a/examples/angular/basic-file-based/src/routes/posts.route.ts b/examples/angular/basic-file-based/src/routes/posts.route.ts
new file mode 100644
index 00000000000..4b6cdc632a9
--- /dev/null
+++ b/examples/angular/basic-file-based/src/routes/posts.route.ts
@@ -0,0 +1,47 @@
+import { Component, computed } from '@angular/core'
+import {
+ createFileRoute,
+ Link,
+ Outlet,
+} from '@tanstack/angular-router-experimental'
+import { fetchPosts } from '../posts'
+
+export const Route = createFileRoute('/posts')({
+ loader: fetchPosts,
+ component: () => PostsLayoutComponent,
+})
+
+@Component({
+ selector: 'route-component',
+ standalone: true,
+ template: `
+
+ `,
+ imports: [Link, Outlet],
+})
+class PostsLayoutComponent {
+ posts = Route.injectLoaderData()
+ postsWithFake = computed(() => [
+ ...this.posts(),
+ { id: 'i-do-not-exist', title: 'Non-existent Post' },
+ ])
+}
diff --git a/examples/angular/basic-file-based/src/styles.css b/examples/angular/basic-file-based/src/styles.css
new file mode 100644
index 00000000000..37a1064738a
--- /dev/null
+++ b/examples/angular/basic-file-based/src/styles.css
@@ -0,0 +1,21 @@
+@import 'tailwindcss';
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+html {
+ color-scheme: light dark;
+}
+* {
+ @apply border-gray-200 dark:border-gray-800;
+}
+body {
+ @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
+}
diff --git a/examples/angular/basic-file-based/tsconfig.app.json b/examples/angular/basic-file-based/tsconfig.app.json
new file mode 100644
index 00000000000..8426ad9558b
--- /dev/null
+++ b/examples/angular/basic-file-based/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.spec.ts"]
+}
diff --git a/examples/angular/basic-file-based/tsconfig.json b/examples/angular/basic-file-based/tsconfig.json
new file mode 100644
index 00000000000..8c124a823d4
--- /dev/null
+++ b/examples/angular/basic-file-based/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": ["ES2022", "dom"],
+ "useDefineForClassFields": false
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/examples/angular/basic-file-based/tsr.config.json b/examples/angular/basic-file-based/tsr.config.json
new file mode 100644
index 00000000000..a6e9abd5e53
--- /dev/null
+++ b/examples/angular/basic-file-based/tsr.config.json
@@ -0,0 +1,5 @@
+{
+ "target": "angular",
+ "routesDirectory": "./src/routes",
+ "generatedRouteTree": "./src/routeTree.gen.ts"
+}
diff --git a/examples/angular/basic-file-based/vite.config.ts b/examples/angular/basic-file-based/vite.config.ts
new file mode 100644
index 00000000000..137b49cf8ea
--- /dev/null
+++ b/examples/angular/basic-file-based/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite'
+import angular from '@analogjs/vite-plugin-angular'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [
+ tailwindcss(),
+ tanstackRouter({
+ target: 'angular',
+ routesDirectory: './src/routes',
+ generatedRouteTree: './src/routeTree.gen.ts',
+ }),
+ angular({
+ tsconfig: './tsconfig.json',
+ }),
+ ],
+})
diff --git a/examples/angular/basic/.editorconfig b/examples/angular/basic/.editorconfig
new file mode 100644
index 00000000000..f166060da1c
--- /dev/null
+++ b/examples/angular/basic/.editorconfig
@@ -0,0 +1,17 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.ts]
+quote_type = single
+ij_typescript_use_double_quotes = false
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/examples/angular/basic/.gitignore b/examples/angular/basic/.gitignore
new file mode 100644
index 00000000000..c0a199b30cb
--- /dev/null
+++ b/examples/angular/basic/.gitignore
@@ -0,0 +1,43 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/node_modules
+npm-debug.log
+yarn-error.log
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+__screenshots__/
+
+# System files
+.DS_Store
+Thumbs.db
diff --git a/examples/angular/basic/.vscode/extensions.json b/examples/angular/basic/.vscode/extensions.json
new file mode 100644
index 00000000000..77b374577de
--- /dev/null
+++ b/examples/angular/basic/.vscode/extensions.json
@@ -0,0 +1,4 @@
+{
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
+ "recommendations": ["angular.ng-template"]
+}
diff --git a/examples/angular/basic/.vscode/launch.json b/examples/angular/basic/.vscode/launch.json
new file mode 100644
index 00000000000..925af837050
--- /dev/null
+++ b/examples/angular/basic/.vscode/launch.json
@@ -0,0 +1,20 @@
+{
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "ng serve",
+ "type": "chrome",
+ "request": "launch",
+ "preLaunchTask": "npm: start",
+ "url": "http://localhost:4200/"
+ },
+ {
+ "name": "ng test",
+ "type": "chrome",
+ "request": "launch",
+ "preLaunchTask": "npm: test",
+ "url": "http://localhost:9876/debug.html"
+ }
+ ]
+}
diff --git a/examples/angular/basic/.vscode/tasks.json b/examples/angular/basic/.vscode/tasks.json
new file mode 100644
index 00000000000..244306f98af
--- /dev/null
+++ b/examples/angular/basic/.vscode/tasks.json
@@ -0,0 +1,42 @@
+{
+ // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "start",
+ "isBackground": true,
+ "problemMatcher": {
+ "owner": "typescript",
+ "pattern": "$tsc",
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": {
+ "regexp": "Changes detected"
+ },
+ "endsPattern": {
+ "regexp": "bundle generation (complete|failed)"
+ }
+ }
+ }
+ },
+ {
+ "type": "npm",
+ "script": "test",
+ "isBackground": true,
+ "problemMatcher": {
+ "owner": "typescript",
+ "pattern": "$tsc",
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": {
+ "regexp": "Changes detected"
+ },
+ "endsPattern": {
+ "regexp": "bundle generation (complete|failed)"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/examples/angular/basic/README.md b/examples/angular/basic/README.md
new file mode 100644
index 00000000000..6bf146f8b52
--- /dev/null
+++ b/examples/angular/basic/README.md
@@ -0,0 +1,59 @@
+# Angular
+
+This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.4.
+
+## Development server
+
+To start a local development server, run:
+
+```bash
+ng serve
+```
+
+Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
+
+## Code scaffolding
+
+Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
+
+```bash
+ng generate component component-name
+```
+
+For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
+
+```bash
+ng generate --help
+```
+
+## Building
+
+To build the project run:
+
+```bash
+ng build
+```
+
+This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
+
+## Running unit tests
+
+To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
+
+```bash
+ng test
+```
+
+## Running end-to-end tests
+
+For end-to-end (e2e) testing, run:
+
+```bash
+ng e2e
+```
+
+Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
+
+## Additional Resources
+
+For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
diff --git a/examples/angular/basic/angular.json b/examples/angular/basic/angular.json
new file mode 100644
index 00000000000..577fe8ee3bd
--- /dev/null
+++ b/examples/angular/basic/angular.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "cli": {
+ "packageManager": "pnpm",
+ "analytics": false
+ },
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular/build:application",
+ "options": {
+ "browser": "src/main.ts",
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": ["src/styles.css"]
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kB",
+ "maximumError": "1MB"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "4kB",
+ "maximumError": "8kB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular/build:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "angular:build:production"
+ },
+ "development": {
+ "buildTarget": "angular:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "test": {
+ "builder": "@angular/build:unit-test"
+ }
+ }
+ }
+ }
+}
diff --git a/examples/angular/basic/package.json b/examples/angular/basic/package.json
new file mode 100644
index 00000000000..018b391c821
--- /dev/null
+++ b/examples/angular/basic/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "tanstack-router-angular-example-basic",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "ng test"
+ },
+ "prettier": {
+ "printWidth": 100,
+ "singleQuote": true,
+ "overrides": [
+ {
+ "files": "*.html",
+ "options": {
+ "parser": "angular"
+ }
+ }
+ ]
+ },
+ "private": true,
+ "packageManager": "pnpm@10.24.0",
+ "dependencies": {
+ "@angular/common": "21.0.4",
+ "@angular/compiler": "21.0.4",
+ "@angular/core": "21.0.4",
+ "@angular/forms": "21.0.4",
+ "@angular/platform-browser": "21.0.4",
+ "@tanstack/angular-router-experimental": "workspace:^",
+ "@tanstack/angular-router-devtools": "workspace:^",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zod": "^4.3.4"
+ },
+ "devDependencies": {
+ "@angular/build": "21.0.4",
+ "@angular/cli": "21.0.4",
+ "@angular/compiler-cli": "21.0.4",
+ "jsdom": "^27.1.0",
+ "typescript": "~5.9.2",
+ "vitest": "^4.0.8"
+ }
+}
diff --git a/examples/angular/basic/public/favicon.ico b/examples/angular/basic/public/favicon.ico
new file mode 100644
index 00000000000..57614f9c967
Binary files /dev/null and b/examples/angular/basic/public/favicon.ico differ
diff --git a/examples/angular/basic/src/app/app.config.ts b/examples/angular/basic/src/app/app.config.ts
new file mode 100644
index 00000000000..800db519f83
--- /dev/null
+++ b/examples/angular/basic/src/app/app.config.ts
@@ -0,0 +1,5 @@
+import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideBrowserGlobalErrorListeners()],
+}
diff --git a/examples/angular/basic/src/app/app.css b/examples/angular/basic/src/app/app.css
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/examples/angular/basic/src/app/app.html b/examples/angular/basic/src/app/app.html
new file mode 100644
index 00000000000..bfa885a599f
--- /dev/null
+++ b/examples/angular/basic/src/app/app.html
@@ -0,0 +1,321 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hello, {{ title() }}
+
Congratulations! Your app is running. 🎉
+
+
+
+
+ @for (item of [ { title: 'Explore the Docs', link: 'https://angular.dev' }, { title: 'Learn
+ with Tutorials', link: 'https://angular.dev/tutorials' }, { title: 'Prompt and best
+ practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, { title: 'CLI Docs',
+ link: 'https://angular.dev/tools/cli' }, { title: 'Angular Language Service', link:
+ 'https://angular.dev/tools/language-service' }, { title: 'Angular DevTools', link:
+ 'https://angular.dev/tools/devtools' }, ]; track item.title) {
+
+ {{ item.title }}
+
+
+
+
+ }
+
+
+
+
+
diff --git a/examples/angular/basic/src/app/app.spec.ts b/examples/angular/basic/src/app/app.spec.ts
new file mode 100644
index 00000000000..07f925593f4
--- /dev/null
+++ b/examples/angular/basic/src/app/app.spec.ts
@@ -0,0 +1,23 @@
+import { TestBed } from '@angular/core/testing'
+import { App } from './app'
+
+describe('App', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [App],
+ }).compileComponents()
+ })
+
+ it('should create the app', () => {
+ const fixture = TestBed.createComponent(App)
+ const app = fixture.componentInstance
+ expect(app).toBeTruthy()
+ })
+
+ it('should render title', async () => {
+ const fixture = TestBed.createComponent(App)
+ await fixture.whenStable()
+ const compiled = fixture.nativeElement as HTMLElement
+ expect(compiled.querySelector('h1')?.textContent).toContain('Hello, angular')
+ })
+})
diff --git a/examples/angular/basic/src/app/app.ts b/examples/angular/basic/src/app/app.ts
new file mode 100644
index 00000000000..8986f4f57dd
--- /dev/null
+++ b/examples/angular/basic/src/app/app.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core'
+import { RouterProvider } from '@tanstack/angular-router-experimental'
+import { router } from './router'
+
+@Component({
+ selector: 'app-root',
+ imports: [RouterProvider],
+ template: ` `,
+})
+export class App {
+ router = router
+}
diff --git a/examples/angular/basic/src/app/router.ts b/examples/angular/basic/src/app/router.ts
new file mode 100644
index 00000000000..60598986977
--- /dev/null
+++ b/examples/angular/basic/src/app/router.ts
@@ -0,0 +1,21 @@
+import { createRouter } from '@tanstack/angular-router-experimental'
+import { Route as HomeRoute } from './routes/home.route'
+import { Route as AboutRoute } from './routes/about.route'
+import { Route as AboutAngularRoute } from './routes/about.angular.route'
+import { Route as PostsRoute } from './routes/posts.route'
+import { Route as PostDetailRoute } from './routes/posts.$postId.route'
+import { Route as RootRoute } from './routes/root.route'
+
+export const routeTree = RootRoute.addChildren([
+ HomeRoute,
+ AboutRoute.addChildren([AboutAngularRoute]),
+ PostsRoute.addChildren([PostDetailRoute]),
+])
+
+export const router = createRouter({ routeTree, defaultPreload: 'render' })
+
+declare module '@tanstack/angular-router-experimental' {
+ interface Register {
+ router: typeof router
+ }
+}
diff --git a/examples/angular/basic/src/app/routes/about.angular.route.ts b/examples/angular/basic/src/app/routes/about.angular.route.ts
new file mode 100644
index 00000000000..2a306c21f05
--- /dev/null
+++ b/examples/angular/basic/src/app/routes/about.angular.route.ts
@@ -0,0 +1,72 @@
+import { Component } from '@angular/core';
+import { createRoute } from '@tanstack/angular-router-experimental';
+import { Route as AboutRoute } from './about.route';
+
+@Component({
+ selector: 'app-about-angular',
+ template: `
+
+
About Angular
+
This is a nested route under the About page.
+
+ TanStack Router provides excellent type safety and developer experience for Angular
+ applications.
+
+
+
Key Features:
+
+ Type-safe routing with full TypeScript support
+ Built-in data loading with loaders
+ Search parameter validation
+ Nested routing support
+ Programmatic navigation
+
+
+
+ `,
+ styles: [
+ `
+ .about-angular {
+ padding: 2rem;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+ h2 {
+ color: #333;
+ margin-bottom: 1rem;
+ }
+ h3 {
+ color: #333;
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+ }
+ p {
+ color: #666;
+ line-height: 1.6;
+ margin-bottom: 1rem;
+ }
+ .features {
+ background-color: #f5f5f5;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-top: 1.5rem;
+ }
+ ul {
+ margin: 0;
+ padding-left: 1.5rem;
+ color: #666;
+ }
+ li {
+ margin-bottom: 0.5rem;
+ line-height: 1.6;
+ }
+ `,
+ ],
+})
+class AboutAngularComponent {}
+
+export const Route = createRoute({
+ getParentRoute: () => AboutRoute,
+ path: 'angular',
+ component: () => AboutAngularComponent,
+});
diff --git a/examples/angular/basic/src/app/routes/about.route.ts b/examples/angular/basic/src/app/routes/about.route.ts
new file mode 100644
index 00000000000..f7f63438067
--- /dev/null
+++ b/examples/angular/basic/src/app/routes/about.route.ts
@@ -0,0 +1,80 @@
+import { Component } from '@angular/core';
+import { createRoute, Link } from '@tanstack/angular-router-experimental';
+import { Route as RootRoute } from './root.route';
+import { Outlet, injectNavigate, injectRouterState } from '@tanstack/angular-router-experimental';
+
+@Component({
+ selector: 'app-about',
+ imports: [Outlet, Link],
+ template: `
+
+
About
+
This is the about page.
+
This example demonstrates how to use TanStack Router with Angular.
+
+
+ About
+ About Angular
+
+
+
+
+ `,
+ styles: [
+ `
+ .about {
+ padding: 2rem;
+ }
+ h2 {
+ color: #333;
+ margin-bottom: 1rem;
+ }
+ p {
+ color: #666;
+ line-height: 1.6;
+ margin-bottom: 1rem;
+ }
+ .about-nav {
+ display: flex;
+ gap: 1rem;
+ margin: 2rem 0;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #eee;
+ }
+ .about-nav a {
+ color: #007bff;
+ text-decoration: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+ }
+ .about-nav a:hover {
+ background-color: #f0f0f0;
+ }
+ .about-nav a.active {
+ background-color: #007bff;
+ color: white;
+ }
+ `,
+ ],
+})
+class AboutComponent {
+ navigate = injectNavigate({ from: '/about' });
+ routerState = injectRouterState();
+
+ isActive(path: string): boolean {
+ return this.routerState().location.pathname === path;
+ }
+}
+
+export const Route = createRoute({
+ getParentRoute: () => RootRoute,
+ path: '/about',
+ component: () => AboutComponent,
+});
diff --git a/examples/angular/basic/src/app/routes/home.route.ts b/examples/angular/basic/src/app/routes/home.route.ts
new file mode 100644
index 00000000000..99bcc7d0494
--- /dev/null
+++ b/examples/angular/basic/src/app/routes/home.route.ts
@@ -0,0 +1,36 @@
+import { Component } from '@angular/core';
+import { createRoute } from '@tanstack/angular-router-experimental';
+import { Route as RootRoute } from './root.route';
+
+@Component({
+ selector: 'app-home',
+ template: `
+
+
Welcome Home!
+
This is the home page of our simple Angular router example.
+
Navigate to the About page using the link in the navigation above.
+
+ `,
+ styles: [
+ `
+ .home {
+ padding: 2rem;
+ }
+ h2 {
+ color: #333;
+ margin-bottom: 1rem;
+ }
+ p {
+ color: #666;
+ line-height: 1.6;
+ }
+ `,
+ ],
+})
+class HomeComponent {}
+
+export const Route = createRoute({
+ getParentRoute: () => RootRoute,
+ path: '/',
+ component: () => HomeComponent,
+});
diff --git a/examples/angular/basic/src/app/routes/posts.$postId.route.ts b/examples/angular/basic/src/app/routes/posts.$postId.route.ts
new file mode 100644
index 00000000000..9fc0c019553
--- /dev/null
+++ b/examples/angular/basic/src/app/routes/posts.$postId.route.ts
@@ -0,0 +1,228 @@
+import { Component } from '@angular/core';
+import { createRoute } from '@tanstack/angular-router-experimental';
+import { Route as PostsRoute } from './posts.route';
+
+// Mock data
+const POSTS: Record = {
+ '1': {
+ id: '1',
+ title: 'First Post',
+ content: 'This is the first post content. It contains detailed information about the topic.',
+ author: 'Alice',
+ },
+ '2': {
+ id: '2',
+ title: 'Second Post',
+ content: 'This is the second post content. It discusses various aspects of the subject.',
+ author: 'Bob',
+ },
+ '3': {
+ id: '3',
+ title: 'Third Post',
+ content: 'This is the third post content. It provides insights and analysis.',
+ author: 'Charlie',
+ },
+ '4': {
+ id: '4',
+ title: 'Fourth Post',
+ content: 'This is the fourth post content. It explores different perspectives.',
+ author: 'Alice',
+ },
+ '5': {
+ id: '5',
+ title: 'Fifth Post',
+ content: 'This is the fifth post content. It concludes the discussion.',
+ author: 'Bob',
+ },
+};
+
+@Component({
+ selector: 'app-post-detail',
+ template: `
+
+ @if (post()) {
+
← Back to Posts
+
+
+ {{ post()!.title }}
+
+ By {{ post()!.author }}
+ Post ID: {{ post()!.id }}
+
+
+
{{ post()!.content }}
+
+
+
+
+
Navigation
+
+ Current Post ID: {{ params().postId }}
+
+
+
+ Post 1
+
+
+ Post 2
+
+
+ Post 3
+
+
+ Post 4
+
+
+ Post 5
+
+
+
+ } @else {
+
+
Post Not Found
+
Post with ID "{{ params().postId }}" does not exist.
+
← Back to Posts
+
+ }
+
+ `,
+ styles: [
+ `
+ .post-detail {
+ padding: 2rem;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+ .back-button {
+ background-color: #007bff;
+ color: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-bottom: 2rem;
+ font-size: 1rem;
+ }
+ .back-button:hover {
+ background-color: #0056b3;
+ }
+ article {
+ background-color: white;
+ padding: 2rem;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ margin-bottom: 2rem;
+ }
+ article h1 {
+ margin: 0 0 1rem 0;
+ color: #333;
+ }
+ .meta {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #eee;
+ color: #666;
+ font-size: 0.9rem;
+ }
+ .meta .author {
+ font-weight: 500;
+ }
+ .content {
+ color: #333;
+ line-height: 1.6;
+ }
+ .navigation {
+ background-color: #f5f5f5;
+ padding: 1.5rem;
+ border-radius: 8px;
+ }
+ .navigation h3 {
+ margin: 0 0 1rem 0;
+ color: #333;
+ }
+ .nav-buttons {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+ .nav-buttons button {
+ padding: 0.5rem 1rem;
+ border: 1px solid #ddd;
+ background-color: white;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+ .nav-buttons button:hover {
+ background-color: #f0f0f0;
+ }
+ .nav-buttons button.active {
+ background-color: #007bff;
+ color: white;
+ border-color: #007bff;
+ }
+ .not-found {
+ text-align: center;
+ padding: 3rem;
+ background-color: #fff3cd;
+ border: 1px solid #ffc107;
+ border-radius: 8px;
+ }
+ .not-found h2 {
+ color: #856404;
+ margin-bottom: 1rem;
+ }
+ .not-found p {
+ color: #856404;
+ margin-bottom: 1.5rem;
+ }
+ .not-found button {
+ background-color: #007bff;
+ color: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ cursor: pointer;
+ }
+ `,
+ ],
+})
+class PostDetailComponent {
+ // Inject route params
+ params = Route.injectParams();
+ navigate = Route.injectNavigate();
+
+ // Get post from loader data
+ post = Route.injectLoaderData();
+
+ navigateToPost(postId: string) {
+ this.navigate({
+ to: '/posts/$postId',
+ params: { postId },
+ });
+ }
+
+ goBack() {
+ this.navigate({
+ to: '/posts',
+ });
+ }
+}
+
+export const Route = createRoute({
+ getParentRoute: () => PostsRoute,
+ path: '/$postId',
+ component: () => PostDetailComponent,
+ loader: async ({ params }) => {
+ // Simulate network delay
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ const postId = params.postId;
+ const post = POSTS[postId];
+
+ // Return null if post not found (component handles this case)
+ return post || null;
+ },
+});
diff --git a/examples/angular/basic/src/app/routes/posts.route.ts b/examples/angular/basic/src/app/routes/posts.route.ts
new file mode 100644
index 00000000000..b5d34139053
--- /dev/null
+++ b/examples/angular/basic/src/app/routes/posts.route.ts
@@ -0,0 +1,213 @@
+import { Component, computed } from '@angular/core';
+import { createRoute, Outlet, Link } from '@tanstack/angular-router-experimental';
+import { Route as RootRoute } from './root.route';
+import { injectSearch, injectNavigate } from '@tanstack/angular-router-experimental';
+import { z } from 'zod';
+
+// Mock data
+const POSTS = [
+ { id: '1', title: 'First Post', content: 'This is the first post content.', author: 'Alice' },
+ { id: '2', title: 'Second Post', content: 'This is the second post content.', author: 'Bob' },
+ { id: '3', title: 'Third Post', content: 'This is the third post content.', author: 'Charlie' },
+ { id: '4', title: 'Fourth Post', content: 'This is the fourth post content.', author: 'Alice' },
+ { id: '5', title: 'Fifth Post', content: 'This is the fifth post content.', author: 'Bob' },
+];
+
+@Component({
+ selector: 'app-posts',
+ imports: [Outlet, Link],
+ template: `
+
+
+
+
Posts
+
+
+
+ Filter by author:
+
+ All Authors
+ Alice
+ Bob
+ Charlie
+
+
+
+
+ Sort by:
+
+ ID
+ Title
+ Author
+
+
+
+
+ Page:
+
+
+
+
+
+ @for (post of filteredPosts(); track post.id) {
+
+
{{ post.title }}
+
By {{ post.author }}
+
{{ post.content }}
+
View Post
+
+ }
+
+
+ @if (filteredPosts().length === 0) {
+
No posts found.
+ }
+
+ `,
+ styles: [
+ `
+ .posts {
+ padding: 2rem;
+ }
+ h2 {
+ color: #333;
+ margin-bottom: 1.5rem;
+ }
+ .controls {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 2rem;
+ padding: 1rem;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ flex-wrap: wrap;
+ }
+ .controls label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ font-weight: 500;
+ }
+ .controls select,
+ .controls input {
+ padding: 0.5rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 1rem;
+ }
+ .posts-list {
+ display: grid;
+ gap: 1rem;
+ }
+ .post-card {
+ padding: 1.5rem;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+ background-color: white;
+ }
+ .post-card:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transform: translateY(-2px);
+ }
+ .post-card h3 {
+ margin: 0 0 0.5rem 0;
+ color: #007bff;
+ }
+ .post-card .author {
+ color: #666;
+ font-size: 0.9rem;
+ margin: 0 0 0.5rem 0;
+ }
+ .post-card .content {
+ color: #333;
+ margin: 0;
+ }
+ .no-posts {
+ text-align: center;
+ color: #666;
+ padding: 2rem;
+ }
+ `,
+ ],
+})
+class PostsComponent {
+ // Inject search params
+ search = injectSearch({ from: '/posts' });
+ navigate = injectNavigate({ from: '/posts' });
+
+ // Computed filtered and sorted posts
+ filteredPosts = computed(() => {
+ const searchParams = this.search();
+ let posts = [...POSTS];
+
+ // Filter by author
+ if (searchParams.author) {
+ posts = posts.filter((p) => p.author === searchParams.author);
+ }
+
+ // Sort
+ const sortBy = searchParams.sort || 'id';
+ posts.sort((a, b) => {
+ if (sortBy === 'id') {
+ return a.id.localeCompare(b.id);
+ }
+ return a[sortBy as keyof typeof a].localeCompare(b[sortBy as keyof typeof b] as string);
+ });
+
+ // Pagination (simple - just show first 3 for demo)
+ const page = searchParams.page || 1;
+ const pageSize = 3;
+ const start = (page - 1) * pageSize;
+ return posts.slice(start, start + pageSize);
+ });
+
+ updateAuthor(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ this.navigate({
+ to: '/posts',
+ search: {
+ ...this.search(),
+ author: target.value || undefined,
+ page: 1, // Reset to first page
+ },
+ });
+ }
+
+ updateSort(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ this.navigate({
+ to: '/posts',
+ search: {
+ ...this.search(),
+ sort: target.value,
+ },
+ });
+ }
+
+ updatePage(event: Event) {
+ const target = event.target as HTMLInputElement;
+ const page = parseInt(target.value, 10);
+ if (page > 0) {
+ this.navigate({
+ to: '/posts',
+ search: {
+ ...this.search(),
+ page: page,
+ },
+ });
+ }
+ }
+}
+
+export const Route = createRoute({
+ getParentRoute: () => RootRoute,
+ path: '/posts',
+ component: () => PostsComponent,
+ validateSearch: z.object({
+ author: z.string().optional(),
+ sort: z.string().optional(),
+ page: z.number().optional(),
+ }),
+});
diff --git a/examples/angular/basic/src/app/routes/root.route.ts b/examples/angular/basic/src/app/routes/root.route.ts
new file mode 100644
index 00000000000..7fcdd930240
--- /dev/null
+++ b/examples/angular/basic/src/app/routes/root.route.ts
@@ -0,0 +1,89 @@
+import { Component } from '@angular/core';
+import {
+ createRootRoute,
+ Outlet,
+ injectNavigate,
+ injectRouterState,
+ Link,
+} from '@tanstack/angular-router-experimental';
+import { TanStackRouterDevtools } from '@tanstack/angular-router-devtools';
+
+@Component({
+ selector: 'app-root-layout',
+ imports: [Outlet, TanStackRouterDevtools, Link],
+ template: `
+
+
+ Angular Router Example
+
+
+
+
+
+
+
+ `,
+ styles: [
+ `
+ .app-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ }
+ nav {
+ border-bottom: 2px solid #eee;
+ padding-bottom: 1rem;
+ margin-bottom: 2rem;
+ }
+ nav h1 {
+ margin: 0 0 1rem 0;
+ }
+ nav ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ gap: 1rem;
+ }
+ nav a {
+ text-decoration: none;
+ color: #007bff;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ }
+ nav a:hover {
+ background-color: #f0f0f0;
+ }
+ nav a.active {
+ font-weight: bold;
+ background-color: #e3f2fd;
+ }
+ main {
+ min-height: 400px;
+ }
+ `,
+ ],
+})
+class RootLayout {
+ navigate = injectNavigate();
+ routerState = injectRouterState();
+
+ isActive(path: string): boolean {
+ const currentPath = this.routerState().location.pathname;
+ return currentPath === path || currentPath.startsWith(path + '/');
+ }
+}
+
+export const Route = createRootRoute({
+ component: () => RootLayout,
+});
diff --git a/examples/angular/basic/src/index.html b/examples/angular/basic/src/index.html
new file mode 100644
index 00000000000..86697fec533
--- /dev/null
+++ b/examples/angular/basic/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Angular
+
+
+
+
+
+
+
+
diff --git a/examples/angular/basic/src/main.ts b/examples/angular/basic/src/main.ts
new file mode 100644
index 00000000000..8192dca694e
--- /dev/null
+++ b/examples/angular/basic/src/main.ts
@@ -0,0 +1,5 @@
+import { bootstrapApplication } from '@angular/platform-browser'
+import { appConfig } from './app/app.config'
+import { App } from './app/app'
+
+bootstrapApplication(App, appConfig).catch((err) => console.error(err))
diff --git a/examples/angular/basic/src/styles.css b/examples/angular/basic/src/styles.css
new file mode 100644
index 00000000000..90d4ee0072c
--- /dev/null
+++ b/examples/angular/basic/src/styles.css
@@ -0,0 +1 @@
+/* You can add global styles to this file, and also import other style files */
diff --git a/examples/angular/basic/tsconfig.app.json b/examples/angular/basic/tsconfig.app.json
new file mode 100644
index 00000000000..a0dcc37c607
--- /dev/null
+++ b/examples/angular/basic/tsconfig.app.json
@@ -0,0 +1,11 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.spec.ts"]
+}
diff --git a/examples/angular/basic/tsconfig.json b/examples/angular/basic/tsconfig.json
new file mode 100644
index 00000000000..2ab7442758f
--- /dev/null
+++ b/examples/angular/basic/tsconfig.json
@@ -0,0 +1,33 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "experimentalDecorators": true,
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "preserve"
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ },
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/examples/angular/basic/tsconfig.spec.json b/examples/angular/basic/tsconfig.spec.json
new file mode 100644
index 00000000000..26230b0b31d
--- /dev/null
+++ b/examples/angular/basic/tsconfig.spec.json
@@ -0,0 +1,10 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": ["vitest/globals"]
+ },
+ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
+}
diff --git a/examples/angular/kitchen-sink/.postcssrc.json b/examples/angular/kitchen-sink/.postcssrc.json
new file mode 100644
index 00000000000..e092dc7c1ef
--- /dev/null
+++ b/examples/angular/kitchen-sink/.postcssrc.json
@@ -0,0 +1,5 @@
+{
+ "plugins": {
+ "@tailwindcss/postcss": {}
+ }
+}
diff --git a/examples/angular/kitchen-sink/angular.json b/examples/angular/kitchen-sink/angular.json
new file mode 100644
index 00000000000..631b8cbf228
--- /dev/null
+++ b/examples/angular/kitchen-sink/angular.json
@@ -0,0 +1,73 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "cli": {
+ "packageManager": "pnpm",
+ "analytics": false
+ },
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-kitchen-sink": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular/build:application",
+ "options": {
+ "conditions": ["module", "style"],
+ "browser": "src/main.ts",
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": ["src/styles.css"]
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kB",
+ "maximumError": "1MB"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "4kB",
+ "maximumError": "8kB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular/build:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "angular-kitchen-sink:build:production"
+ },
+ "development": {
+ "buildTarget": "angular-kitchen-sink:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "test": {
+ "builder": "@angular/build:unit-test"
+ }
+ }
+ }
+ }
+}
diff --git a/examples/angular/kitchen-sink/package.json b/examples/angular/kitchen-sink/package.json
new file mode 100644
index 00000000000..6271ebd4f74
--- /dev/null
+++ b/examples/angular/kitchen-sink/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "tanstack-router-angular-example-kitchen-sink",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "ng test"
+ },
+ "prettier": {
+ "printWidth": 100,
+ "singleQuote": true,
+ "overrides": [
+ {
+ "files": "*.html",
+ "options": {
+ "parser": "angular"
+ }
+ }
+ ]
+ },
+ "private": true,
+ "packageManager": "pnpm@10.24.0",
+ "dependencies": {
+ "@angular/common": "21.0.4",
+ "@angular/compiler": "21.0.4",
+ "@angular/core": "21.0.4",
+ "@angular/forms": "21.0.4",
+ "@angular/platform-browser": "21.0.4",
+ "@tanstack/angular-router-experimental": "workspace:^",
+ "@tanstack/angular-router-devtools": "workspace:^",
+ "immer": "^10.1.1",
+ "redaxios": "^0.5.1",
+ "rxjs": "~7.8.2",
+ "tslib": "^2.3.0",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "@angular/build": "21.0.4",
+ "@angular/cli": "21.0.4",
+ "@angular/compiler-cli": "21.0.4",
+ "@tailwindcss/postcss": "^4.1.18",
+ "jsdom": "^27.1.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "typescript": "~5.9.2",
+ "vitest": "^4.0.8"
+ }
+}
diff --git a/examples/angular/kitchen-sink/public/favicon.ico b/examples/angular/kitchen-sink/public/favicon.ico
new file mode 100644
index 00000000000..1a1751676f7
Binary files /dev/null and b/examples/angular/kitchen-sink/public/favicon.ico differ
diff --git a/examples/angular/kitchen-sink/src/expensive.route.ts b/examples/angular/kitchen-sink/src/expensive.route.ts
new file mode 100644
index 00000000000..3794ed4cbeb
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/expensive.route.ts
@@ -0,0 +1,35 @@
+import { Component } from '@angular/core'
+import { createLazyRoute, injectErrorState } from '@tanstack/angular-router-experimental'
+import { injectRouteErrorHandler } from '@tanstack/angular-router-experimental/experimental'
+
+@Component({
+ selector: 'app-expensive',
+ standalone: true,
+ template: `
+
+ I am an "expensive" component... which really just means that I was code-split 😉
+
+ Throw error
+ `,
+})
+class ExpensiveComponent {
+ errorHandler = injectRouteErrorHandler({ from: '/expensive' })
+
+ throwError() {
+ this.errorHandler.throw(new Error('Test error'))
+ }
+}
+
+export const Route = createLazyRoute('/expensive')({
+ component: () => ExpensiveComponent,
+ // errorComponent: () => ExpensiveErrorComponent,
+})
+
+@Component({
+ selector: 'app-expensive-error',
+ standalone: true,
+ template: ` It broke! {{ errorState.error.message }}
`,
+})
+class ExpensiveErrorComponent {
+ errorState = injectErrorState()
+}
diff --git a/examples/angular/kitchen-sink/src/index.html b/examples/angular/kitchen-sink/src/index.html
new file mode 100644
index 00000000000..7d9023c2dfc
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Angular Kitchen Sink
+
+
+
+
+
+
+
+
diff --git a/examples/angular/kitchen-sink/src/injectMutation.ts b/examples/angular/kitchen-sink/src/injectMutation.ts
new file mode 100644
index 00000000000..f8ef6a33b79
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/injectMutation.ts
@@ -0,0 +1,40 @@
+import { signal } from '@angular/core'
+
+export function injectMutation(opts: {
+ fn: (variables: TVariables) => Promise
+ onSuccess?: (ctx: { data: TData }) => void | Promise
+}) {
+ const submittedAt = signal(undefined)
+ const variables = signal(undefined)
+ const error = signal(undefined)
+ const data = signal(undefined)
+ const status = signal<'idle' | 'pending' | 'success' | 'error'>('idle')
+
+ const mutate = async (vars: TVariables): Promise => {
+ status.set('pending')
+ submittedAt.set(Date.now())
+ variables.set(vars)
+ //
+ try {
+ const result = await opts.fn(vars)
+ await opts.onSuccess?.({ data: result })
+ status.set('success')
+ error.set(undefined)
+ data.set(result)
+ return result
+ } catch (err: any) {
+ status.set('error')
+ error.set(err)
+ throw err
+ }
+ }
+
+ return {
+ status,
+ variables,
+ submittedAt,
+ mutate,
+ error,
+ data,
+ }
+}
diff --git a/examples/angular/kitchen-sink/src/main.ts b/examples/angular/kitchen-sink/src/main.ts
new file mode 100644
index 00000000000..8c61bbdaa33
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/main.ts
@@ -0,0 +1,1311 @@
+import {
+ Component,
+ computed,
+ effect,
+ EnvironmentInjector,
+ inject,
+ Injector,
+ input,
+ linkedSignal,
+ ProviderToken,
+ signal,
+} from '@angular/core'
+import './router-register'
+import { bootstrapApplication } from '@angular/platform-browser'
+import {
+ Outlet,
+ RouterProvider,
+ createRootRouteWithContext,
+ createRoute,
+ createRouter,
+ injectRouter,
+ injectRouterState,
+ Link,
+ LinkOptions,
+ notFound,
+ redirect,
+ retainSearchParams,
+ RouterContextOptions,
+} from '@tanstack/angular-router-experimental'
+import { TanStackRouterDevtoolsInProd } from '@tanstack/angular-router-devtools'
+import { z } from 'zod'
+import { injectMutation } from './injectMutation'
+import './styles.css'
+import { JsonPipe } from '@angular/common'
+import { UsersService, InvoiceService, Invoice } from './services'
+
+@Component({
+ selector: 'app-spinner',
+ standalone: true,
+ template: `
+
+ ⍥
+
+ `,
+})
+class SpinnerComponent {
+ show = input(true)
+ wait = input<`delay-${number}` | undefined>(undefined)
+}
+
+@Component({
+ selector: 'app-invoice-fields',
+ standalone: true,
+ styles: `
+ :host {
+ display: block;
+ }
+ `,
+ template: `
+
+ `,
+})
+class InvoiceFieldsComponent {
+ invoice = input.required()
+ disabled = input(false)
+}
+
+type UsersViewSortBy = 'name' | 'id' | 'email'
+
+type MissingUserData = {
+ userId: number
+}
+
+function isMissingUserData(data: unknown): data is MissingUserData {
+ return (
+ typeof data === 'object' &&
+ data !== null &&
+ typeof (data as { userId?: unknown }).userId === 'number'
+ )
+}
+
+@Component({
+ selector: 'app-users-not-found',
+ standalone: true,
+ template: `
+
+
User not found
+
+ @if (userId() !== undefined) {
+ We couldn't find a user with ID {{ userId() }}.
+ } @else {
+ We couldn't find the requested user.
+ }
+
+
Rendered by the "{{ routeId() }}" route.
+
Pick another user from the list on the left to continue.
+
+ `,
+})
+class UsersNotFoundComponent {
+ data = signal(null)
+ routeId = signal('')
+
+ userId = computed(() => {
+ const d = this.data()
+ return isMissingUserData(d) ? d.userId : undefined
+ })
+}
+
+const rootRoute = createRootRouteWithContext<{
+ auth: Auth
+ inject: Injector['get']
+}>()({
+ component: () => RootComponent,
+ errorComponent: () => ErrorComponent,
+})
+
+@Component({
+ selector: 'app-router-spinner',
+ standalone: true,
+ imports: [SpinnerComponent],
+ template: ` `,
+})
+class RouterSpinnerComponent {
+ isLoading = injectRouterState({ select: (s) => s.status === 'pending' })
+}
+
+@Component({
+ selector: 'app-top-loading-bar',
+ standalone: true,
+ styles: `
+ @keyframes top-loader-indeterminate {
+ 0% {
+ transform: translateX(-110%);
+ }
+ 100% {
+ transform: translateX(260%);
+ }
+ }
+
+ .top-loader-indeterminate {
+ animation: top-loader-indeterminate 1.1s linear infinite;
+ }
+ `,
+ template: `
+
+ `,
+})
+class TopLoadingBarComponent {
+ isLoading = injectRouterState({ select: (s) => s.status === 'pending' })
+}
+
+@Component({
+ selector: 'app-breadcrumbs',
+ standalone: true,
+ imports: [Link],
+ template: `
+ @if (matchesWithCrumbs().length > 0) {
+
+
+
+ }
+ `,
+})
+class BreadcrumbsComponent {
+ routerState = injectRouterState()
+
+ matchesWithCrumbs = computed(() => {
+ const state = this.routerState()
+ const matches = state.matches
+
+ // Filter out pending matches
+ if (matches.some((match) => match.status === 'pending')) {
+ return []
+ }
+
+ // Filter matches that have loaderData.crumb
+ return matches.filter((match) => {
+ return match.loaderData && typeof match.loaderData === 'object' && 'crumb' in match.loaderData
+ }) as Array<{ id: string; pathname: string; loaderData: { crumb: string } }>
+ })
+}
+
+@Component({
+ selector: 'app-root-layout',
+ standalone: true,
+ imports: [
+ Outlet,
+ Link,
+ TopLoadingBarComponent,
+ RouterSpinnerComponent,
+ TanStackRouterDevtoolsInProd,
+ BreadcrumbsComponent,
+ ],
+ template: `
+
+
+
+
+
+ @for (link of links(); track link[0]) {
+
+ }
+
+
+
+
+
+
+
+ `,
+})
+class RootComponent {
+ authSignal = signal(auth)
+ routerState = injectRouterState()
+
+ links = computed(() => {
+ const currentAuth = this.authSignal()
+ const baseLinks: Array<[string, string]> = [
+ ['/', 'Home'],
+ ['/dashboard', 'Dashboard'],
+ ['/expensive', 'Expensive'],
+ ['/route-a', 'Pathless Layout A'],
+ ['/route-b', 'Pathless Layout B'],
+ ['/profile', 'Profile'],
+ ]
+ if (currentAuth.status === 'loggedOut') {
+ return [...baseLinks, ['/login', 'Login']]
+ }
+ return baseLinks
+ })
+
+ isActive(path: string): boolean {
+ const currentPath = this.routerState().location.pathname
+ return currentPath === path || currentPath.startsWith(path + '/')
+ }
+}
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexComponent,
+})
+
+@Component({
+ selector: 'app-index',
+ standalone: true,
+ imports: [Link],
+ template: `
+
+
Welcome Home!
+
+
+ 1 New Invoice
+
+
+
+ As you navigate around take note of the UX. It should feel suspense-like, where routes are
+ only rendered once all of their data and elements are ready.
+
+ To exaggerate async effects, play with the artificial request delay slider in the
+ bottom-left corner.
+
+ The last 2 sliders determine if link-hover preloading is enabled (and how long those
+ preloads stick around) and also whether to cache rendered route data (and for how long).
+ Both of these default to 0 (or off).
+
+
+ `,
+})
+class IndexComponent {
+ invoiceRoute = invoiceRoute
+}
+
+const dashboardLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'dashboard',
+ loader: () => ({ crumb: 'Dashboard' }),
+ component: () => DashboardLayoutComponent,
+})
+
+@Component({
+ selector: 'app-dashboard-layout',
+ standalone: true,
+ imports: [Outlet, Link],
+ template: `
+
+
Dashboard
+
+
+
+
+ `,
+})
+class DashboardLayoutComponent {
+ routerState = injectRouterState()
+ links: Array<[string, string, boolean?]> = [
+ ['/dashboard', 'Summary', true],
+ ['/dashboard/invoices', 'Invoices'],
+ ['/dashboard/users', 'Users'],
+ ]
+
+ isActive(path: string, exact?: boolean): boolean {
+ const currentPath = this.routerState().location.pathname
+ if (exact) {
+ return currentPath === path
+ }
+ return currentPath === path || currentPath.startsWith(path + '/')
+ }
+}
+
+const dashboardIndexRoute = createRoute({
+ getParentRoute: () => dashboardLayoutRoute,
+ path: '/',
+ loader: ({ context }) => {
+ const invoiceService = context.inject(InvoiceService)
+ return invoiceService.fetchInvoices()
+ },
+ component: () => DashboardIndexComponent,
+})
+
+@Component({
+ selector: 'app-dashboard-index',
+ standalone: true,
+ template: `
+
+
+ Welcome to the dashboard! You have
+ {{ invoices().length }} total invoices .
+
+
+ `,
+})
+class DashboardIndexComponent {
+ invoices = dashboardIndexRoute.injectLoaderData()
+}
+
+const invoicesLayoutRoute = createRoute({
+ getParentRoute: () => dashboardLayoutRoute,
+ path: 'invoices',
+ loader: ({ context }) => {
+ const invoiceService = context.inject(InvoiceService)
+ return invoiceService.fetchInvoices()
+ },
+ component: () => InvoicesLayoutComponent,
+})
+
+@Component({
+ selector: 'app-invoices-layout',
+ standalone: true,
+ imports: [Outlet, Link, SpinnerComponent],
+ preserveWhitespaces: false,
+ template: `
+
+
+ @for (invoice of invoices(); track invoice.id) {
+
+ }
+
+
+
+
+
+ `,
+})
+class InvoicesLayoutComponent {
+ invoices = invoicesLayoutRoute.injectLoaderData()
+ routerState = injectRouterState()
+
+ isActive(path: string): boolean {
+ return (
+ this.routerState().location.pathname === path ||
+ this.routerState().location.pathname.startsWith(path + '/')
+ )
+ }
+
+ isPending(invoiceId: number): boolean {
+ const matches = this.routerState().matches
+ const match = matches.find(
+ (m) => m.routeId === invoiceRoute.id && m.params?.invoiceId === invoiceId,
+ )
+ return match?.status === 'pending' || false
+ }
+}
+
+const invoicesIndexRoute = createRoute({
+ getParentRoute: () => invoicesLayoutRoute,
+ path: '/',
+ component: () => InvoicesIndexComponent,
+})
+
+@Component({
+ selector: 'app-invoices-index',
+ standalone: true,
+ imports: [InvoiceFieldsComponent, SpinnerComponent],
+ template: `
+
+ `,
+})
+class InvoicesIndexComponent {
+ router = injectRouter()
+ routerContext = invoicesIndexRoute.injectRouteContext()
+
+ createInvoiceMutation = injectMutation({
+ fn: (variables: Partial) => {
+ const context = this.routerContext()
+ const invoiceService = context.inject(InvoiceService)
+ return invoiceService.postInvoice(variables)
+ },
+ onSuccess: () => this.router.invalidate(),
+ })
+
+ emptyInvoice = {
+ body: '',
+ title: '',
+ } as Invoice
+
+ onSubmit(event: Event) {
+ event.preventDefault()
+ event.stopPropagation()
+ const form = event.target as HTMLFormElement
+ const formData = new FormData(form)
+ this.createInvoiceMutation.mutate({
+ title: formData.get('title') as string,
+ body: formData.get('body') as string,
+ })
+ }
+}
+
+const invoiceRoute = createRoute({
+ getParentRoute: () => invoicesLayoutRoute,
+ path: '$invoiceId',
+ params: {
+ parse: (params) => ({
+ invoiceId: z.number().int().parse(Number(params.invoiceId)),
+ }),
+ stringify: ({ invoiceId }) => ({ invoiceId: `${invoiceId}` }),
+ },
+ validateSearch: (search) =>
+ z
+ .object({
+ showNotes: z.boolean().optional(),
+ notes: z.string().optional(),
+ })
+ .parse(search),
+ loader: async ({ params: { invoiceId }, context }) => {
+ const invoiceService = context.inject(InvoiceService)
+ const invoice = await invoiceService.fetchInvoiceById(invoiceId)
+ if (!invoice) throw notFound()
+ return invoice
+ },
+ component: () => InvoiceComponent,
+ pendingComponent: () => SpinnerComponent,
+})
+
+@Component({
+ selector: 'app-invoice',
+ standalone: true,
+ imports: [Link, InvoiceFieldsComponent],
+ template: `
+
+ `,
+})
+class InvoiceComponent {
+ router = injectRouter()
+ search = invoiceRoute.injectSearch()
+ navigate = invoiceRoute.injectNavigate()
+ invoice = invoiceRoute.injectLoaderData()
+ routerContext = invoiceRoute.injectRouteContext()
+ updateInvoiceMutation = injectMutation({
+ fn: (variables: Partial) => {
+ const context = this.routerContext()
+ const invoiceService = context.inject(InvoiceService)
+ return invoiceService.patchInvoice(this.invoice().id, variables)
+ },
+ onSuccess: () => this.router.invalidate(),
+ })
+ notes = signal(this.search().notes ?? '')
+
+ #updateNotes = effect(() => {
+ const currentNotes = this.notes()
+ this.navigate({
+ search: (old) => ({
+ ...old,
+ notes: currentNotes ? currentNotes : undefined,
+ }),
+ params: true,
+ replace: true,
+ })
+ })
+
+ onSubmit(event: Event) {
+ event.preventDefault()
+ event.stopPropagation()
+ const form = event.target as HTMLFormElement
+ const formData = new FormData(form)
+ this.updateInvoiceMutation.mutate({
+ id: this.invoice().id,
+ title: formData.get('title') as string,
+ body: formData.get('body') as string,
+ })
+ }
+
+ toggleSearchNotesLinkOptions() {
+ return {
+ to: '/dashboard/invoices/$invoiceId',
+ params: { invoiceId: this.invoice().id },
+ search: (old) => ({
+ ...old,
+ showNotes: old.showNotes ? undefined : true,
+ }),
+ } as LinkOptions
+ }
+}
+
+const usersLayoutRoute = createRoute({
+ getParentRoute: () => dashboardLayoutRoute,
+ path: 'users',
+ validateSearch: z.object({
+ usersView: z
+ .object({
+ sortBy: z.enum(['name', 'id', 'email']).optional(),
+ filterBy: z.string().optional(),
+ })
+ .optional(),
+ }).parse,
+ search: {
+ middlewares: [retainSearchParams(['usersView'])],
+ },
+ loaderDeps: ({ search: { usersView } }) => ({
+ filterBy: usersView?.filterBy,
+ sortBy: usersView?.sortBy ?? 'name',
+ }),
+ loader: async ({ deps, context }) => {
+ const usersService = context.inject(UsersService)
+ const users = await usersService.fetchUsers(deps)
+ return { users, crumb: 'Users' }
+ },
+ notFoundComponent: () => UsersNotFoundComponent,
+ component: () => UsersLayoutComponent,
+})
+
+@Component({
+ selector: 'app-users-layout',
+ standalone: true,
+ imports: [Outlet, Link, SpinnerComponent],
+ template: `
+
+
+
+
Sort By:
+
+ @for (option of sortOptions; track option) {
+ {{ option }}
+ }
+
+
+
+ @for (user of filteredUsers(); track user.id) {
+
+ }
+
+ Need to see how not-found errors look?
+
+ Try loading user 404
+
+
+
+
+
+
+
+ `,
+})
+class UsersLayoutComponent {
+ navigate = usersLayoutRoute.injectNavigate()
+ searchSignal = usersLayoutRoute.injectSearch()
+ loaderData = usersLayoutRoute.injectLoaderData()
+ users = computed(() => this.loaderData().users)
+ routerState = injectRouterState()
+ sortOptions = ['name', 'id', 'email']
+
+ search = computed(() => this.searchSignal())
+ sortBy = computed(() => this.search().usersView?.sortBy ?? 'name')
+ filterBy = computed(() => this.search().usersView?.filterBy)
+ filterDraft = linkedSignal(() => this.filterBy() ?? '')
+
+ #updateFilter = effect(() => {
+ const draft = this.filterDraft()
+ this.navigate({
+ search: (old) => ({
+ ...old,
+ usersView: {
+ ...old.usersView,
+ filterBy: draft || undefined,
+ },
+ }),
+ replace: true,
+ })
+ })
+
+ sortedUsers = computed(() => {
+ const usersList = this.users()
+ if (!usersList) return []
+ const sort = this.sortBy()
+ if (!sort) return usersList
+ return [...usersList].sort((a, b) => {
+ return a[sort] > b[sort] ? 1 : -1
+ })
+ })
+
+ filteredUsers = computed(() => {
+ const sorted = this.sortedUsers()
+ const filter = this.filterBy()
+ if (!filter) return sorted
+ return sorted.filter((user) => user.name.toLowerCase().includes(filter.toLowerCase()))
+ })
+
+ setSortBy(sortBy: UsersViewSortBy) {
+ this.navigate({
+ search: (old) => ({
+ ...old,
+ usersView: {
+ ...(old.usersView ?? {}),
+ sortBy,
+ },
+ }),
+ replace: true,
+ })
+ }
+
+ isActive(path: string, userId?: number): boolean {
+ const currentPath = this.routerState().location.pathname
+ const currentSearch = this.search()
+ if (userId !== undefined) {
+ return currentPath === path && (currentSearch as { userId?: number }).userId === userId
+ }
+ return currentPath === path || currentPath.startsWith(path + '/')
+ }
+
+ isPending(userId: number): boolean {
+ const matches = this.routerState().matches
+ const match = matches.find(
+ (m) =>
+ m.routeId === userRoute.id &&
+ m.search &&
+ (m.search as { userId?: number }).userId === userId,
+ )
+ return match?.status === 'pending' || false
+ }
+}
+
+const usersIndexRoute = createRoute({
+ getParentRoute: () => usersLayoutRoute,
+ path: '/',
+ component: () => UsersIndexComponent,
+})
+
+@Component({
+ selector: 'app-users-index',
+ standalone: true,
+ template: `
+
+
+ Normally, setting default search parameters would either need to be done manually in every
+ link to a page, or as a side-effect (not a great experience).
+
+
+ Instead, we can use search filters to provide defaults or even persist
+ search params for links to routes (and child routes).
+
+
+ A good example of this is the sorting and filtering of the users list. In a traditional
+ router, both would be lost while navigating around individual users or even changing each
+ sort/filter option unless each state was manually passed from the current route into each
+ new link we created (that's a lot of tedious and error-prone work). With TanStack router and
+ search filters, they are persisted with little effort.
+
+
+ `,
+})
+class UsersIndexComponent { }
+
+const userRoute = createRoute({
+ getParentRoute: () => usersLayoutRoute,
+ path: 'user',
+ validateSearch: z.object({
+ userId: z.number(),
+ }),
+ loaderDeps: ({ search: { userId } }) => ({
+ userId,
+ }),
+ loader: async ({ deps: { userId }, context }) => {
+ const usersService = context.inject(UsersService)
+ const user = await usersService.fetchUserById(userId)
+
+ if (!user) {
+ throw notFound({
+ data: {
+ userId,
+ },
+ })
+ }
+
+ return { user, crumb: user.name }
+ },
+ component: () => UserComponent,
+})
+
+@Component({
+ selector: 'app-user',
+ standalone: true,
+ template: `
+ {{ user().name }}
+ {{ userJson() }}
+ `,
+})
+class UserComponent {
+ loaderData = userRoute.injectLoaderData()
+ user = computed(() => this.loaderData().user)
+ userJson = computed(() => JSON.stringify(this.user(), null, 2))
+}
+
+const expensiveRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'expensive',
+}).lazy(() => import('./expensive.route').then((d) => d.Route))
+
+const authPathlessLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: 'auth',
+ beforeLoad: ({ context, location }) => {
+ if (context.auth.status === 'loggedOut') {
+ console.log(location)
+ throw redirect({
+ to: loginRoute.to,
+ search: {
+ redirect: location.href,
+ },
+ })
+ }
+
+ return {
+ username: auth.username,
+ }
+ },
+})
+
+const profileRoute = createRoute({
+ getParentRoute: () => authPathlessLayoutRoute,
+ path: 'profile',
+ component: () => ProfileComponent,
+})
+
+@Component({
+ selector: 'app-profile',
+ standalone: true,
+ template: `
+
+
+ Username:{{ username() }}
+
+
+ Log out
+
+
+ `,
+})
+class ProfileComponent {
+ router = injectRouter()
+ routeContext = profileRoute.injectRouteContext()
+ username = computed(() => this.routeContext().username)
+
+ logout() {
+ auth.logout()
+ this.router.invalidate()
+ }
+}
+
+const loginRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'login',
+ validateSearch: z.object({
+ redirect: z.string().optional(),
+ }),
+ loaderDeps: ({ search: { redirect } }) => ({ redirect }),
+ loader: ({ context, deps }) => {
+ // This is not done in the other examples, but since in angular
+ // we don't have transitions, this loader can prevent double renders
+ // by doing an early redirect.
+ if (context.auth.status === 'loggedIn' && deps.redirect) {
+ throw redirect({ to: deps.redirect })
+ }
+ },
+}).update({
+ component: () => LoginComponent,
+})
+
+@Component({
+ selector: 'app-login',
+ standalone: true,
+ template: `
+ @if (status() === 'loggedIn') {
+
+ Logged in as
{{ auth().username }}
+
+
+ Log out
+
+
+
+ } @else {
+
+
You must log in!
+
+
+
+
+ Login
+
+
+
+ }
+ `,
+})
+class LoginComponent {
+ router = injectRouter()
+ routeContext = loginRoute.injectRouteContext({
+ select: ({ auth }) => ({ auth, status: auth.status }),
+ })
+ search = loginRoute.injectSearch()
+ username = signal('')
+
+ auth = computed(() => this.routeContext().auth)
+ status = computed(() => this.routeContext().status)
+
+ #redirectIfLoggedIn = effect(() => {
+ if (this.status() === 'loggedIn' && this.search().redirect) {
+ this.router.history.push(this.search().redirect!)
+ }
+ })
+
+ onSubmit(event: Event) {
+ event.preventDefault()
+ this.auth().login(this.username())
+ this.router.invalidate()
+ }
+
+ logout() {
+ this.auth().logout()
+ this.router.invalidate()
+ }
+}
+
+const pathlessLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: 'pathless-layout',
+ component: () => PathlessLayoutComponent,
+})
+
+@Component({
+ selector: 'app-pathless-layout',
+ standalone: true,
+ imports: [Outlet],
+ template: `
+
+ `,
+})
+class PathlessLayoutComponent { }
+
+const pathlessLayoutARoute = createRoute({
+ getParentRoute: () => pathlessLayoutRoute,
+ path: 'route-a',
+ component: () => PathlessLayoutAComponent,
+})
+
+@Component({
+ selector: 'app-pathless-layout-a',
+ standalone: true,
+ template: `
+
+ `,
+})
+class PathlessLayoutAComponent { }
+
+const pathlessLayoutBRoute = createRoute({
+ getParentRoute: () => pathlessLayoutRoute,
+ path: 'route-b',
+ component: () => PathlessLayoutBComponent,
+})
+
+@Component({
+ selector: 'app-pathless-layout-b',
+ standalone: true,
+ template: `
+
+ `,
+})
+class PathlessLayoutBComponent { }
+
+const routeTree = rootRoute.addChildren([
+ indexRoute,
+ dashboardLayoutRoute.addChildren([
+ dashboardIndexRoute,
+ invoicesLayoutRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+ usersLayoutRoute.addChildren([usersIndexRoute, userRoute]),
+ ]),
+ expensiveRoute,
+ authPathlessLayoutRoute.addChildren([profileRoute]),
+ loginRoute,
+ pathlessLayoutRoute.addChildren([pathlessLayoutARoute, pathlessLayoutBRoute]),
+])
+
+export const router = createRouter({
+ routeTree,
+ defaultPendingComponent: () => SpinnerComponent,
+ // defaultErrorComponent: () => ErrorComponent,
+ context: {
+ auth: undefined!,
+ inject: undefined!,
+ },
+ defaultPreload: 'intent',
+ scrollRestoration: true,
+})
+
+const auth: Auth = {
+ status: 'loggedOut',
+ username: undefined,
+ login: (username: string) => {
+ auth.username = username
+ auth.status = 'loggedIn'
+ },
+ logout: () => {
+ auth.status = 'loggedOut'
+ auth.username = undefined
+ },
+}
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [RouterProvider],
+ template: `
+
+
+
+
+
+ Fast
+
+
+ Fast 3G
+
+
+ Slow 3G
+
+
+
+
Loader Delay: {{ loaderDelay() }}ms
+
+
+
+
+
+
+ Reset to Default
+
+
+
+
defaultPendingMs: {{ pendingMs() }}ms
+
+
+
+
defaultPendingMinMs: {{ pendingMinMs() }}ms
+
+
+
+
+ `,
+})
+class AppComponent {
+ router = router
+ environmentInjector = inject(EnvironmentInjector)
+
+ routerContext: RouterContextOptions['context'] = {
+ auth,
+ inject: (token: ProviderToken) => this.environmentInjector.get(token),
+ }
+
+ loaderDelay = useSessionStorage('loaderDelay', 500)
+ pendingMs = useSessionStorage('pendingMs', 1000)
+ pendingMinMs = useSessionStorage('pendingMinMs', 500)
+
+ setLoaderDelay(value: number) {
+ this.loaderDelay.set(value)
+ }
+
+ setPendingMs(value: number) {
+ this.pendingMs.set(value)
+ }
+
+ setPendingMinMs(value: number) {
+ this.pendingMinMs.set(value)
+ }
+
+ resetPending() {
+ this.pendingMs.set(1000)
+ this.pendingMinMs.set(500)
+ }
+}
+
+type Auth = {
+ login: (username: string) => void
+ logout: () => void
+ status: 'loggedOut' | 'loggedIn'
+ username?: string
+}
+
+@Component({
+ selector: 'app-error',
+ standalone: true,
+ imports: [JsonPipe],
+ template: `
+
+
Error
+
{{ error() | json }}
+
Reset
+
+ `,
+})
+class ErrorComponent {
+ error = signal(null)
+ router = injectRouter()
+
+ reset() {
+ this.router.invalidate()
+ }
+}
+
+function useSessionStorage(key: string, initialValue: T) {
+ const stored = sessionStorage.getItem(key)
+ const value = signal(stored ? JSON.parse(stored) : initialValue)
+
+ effect(() => {
+ sessionStorage.setItem(key, JSON.stringify(value()))
+ })
+
+ return value
+}
+
+bootstrapApplication(AppComponent).catch((err) => console.error(err))
diff --git a/examples/angular/kitchen-sink/src/router-register.ts b/examples/angular/kitchen-sink/src/router-register.ts
new file mode 100644
index 00000000000..3da25136c15
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/router-register.ts
@@ -0,0 +1,9 @@
+import type { router } from './main'
+
+declare module '@tanstack/angular-router-experimental' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+export {}
diff --git a/examples/angular/kitchen-sink/src/services.ts b/examples/angular/kitchen-sink/src/services.ts
new file mode 100644
index 00000000000..b0b1c724653
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/services.ts
@@ -0,0 +1,180 @@
+import { inject, Injectable } from '@angular/core'
+import { HttpClient } from '@angular/common/http'
+import { lastValueFrom, tap } from 'rxjs'
+import { actionDelayFn, loaderDelayFn, shuffle } from './utils'
+
+export type Invoice = {
+ id: number
+ title: string
+ body: string
+}
+
+export interface User {
+ id: number
+ name: string
+ username: string
+ email: string
+ address: Address
+ phone: string
+ website: string
+ company: Company
+}
+
+export interface Address {
+ street: string
+ suite: string
+ city: string
+ zipcode: string
+ geo: Geo
+}
+
+export interface Geo {
+ lat: string
+ lng: string
+}
+
+export interface Company {
+ name: string
+ catchPhrase: string
+ bs: string
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class InvoiceService {
+ readonly #httpClient = inject(HttpClient)
+
+ #invoices: Array = []
+
+ #invoicesPromise: Promise | undefined
+
+ private ensureInvoices = async () => {
+ if (!this.#invoicesPromise) {
+ this.#invoicesPromise = lastValueFrom(
+ this.#httpClient
+ .get>('https://jsonplaceholder.typicode.com/posts')
+ .pipe(tap((data) => (this.#invoices = data.slice(0, 10)))),
+ )
+ }
+ await this.#invoicesPromise
+ }
+
+ fetchInvoices() {
+ return loaderDelayFn(() => this.ensureInvoices().then(() => this.#invoices))
+ }
+
+ fetchInvoiceById(id: number) {
+ return loaderDelayFn(() =>
+ this.ensureInvoices().then(() => this.#invoices.find((invoice) => invoice.id === id)),
+ )
+ }
+
+ postInvoice(partialInvoice: Partial) {
+ return actionDelayFn(() => {
+ if (partialInvoice.title?.includes('error')) {
+ console.error('error')
+ throw new Error('Ouch!')
+ }
+
+ const invoice = {
+ id: this.#invoices.length + 1,
+ title: partialInvoice.title ?? `New Invoice ${String(Date.now()).slice(0, 5)}`,
+ body:
+ partialInvoice.body ??
+ shuffle(
+ `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+ Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam nec ante.
+ Vestibulum sapien. Proin quam. Etiam ultrices. Suspendisse in justo eu magna luctus suscipit. Sed lectus. Integer euismod lacus luctus magna. Integer id quam. Morbi mi. Quisque nisl felis, venenatis tristique, dignissim in, ultrices sit amet, augue. Proin sodales libero eget ante.
+ `.split(' '),
+ ).join(' '),
+ }
+
+ this.#invoices = [...this.#invoices, invoice]
+ return invoice
+ })
+ }
+
+ patchInvoice(id: number, partialInvoice: Partial) {
+ return actionDelayFn(() => {
+ if (partialInvoice.title?.includes('error')) {
+ console.error('error')
+ throw new Error('Ouch!')
+ }
+ const index = this.#invoices.findIndex((invoice) => invoice.id === id)
+
+ if (index === -1) {
+ throw new Error('Invoice not found.')
+ }
+
+ const newArray = [...this.#invoices]
+ newArray[index] = { ...this.#invoices[index], ...partialInvoice, id }
+
+ this.#invoices = newArray
+ return this.#invoices[index]
+ })
+ }
+}
+
+type UsersSortBy = 'name' | 'id' | 'email'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class UsersService {
+ readonly #httpClient = inject(HttpClient)
+
+ #users: Array = []
+
+ #usersPromise: Promise | undefined
+
+ private ensureUsers = async () => {
+ if (!this.#usersPromise) {
+ this.#usersPromise = lastValueFrom(
+ this.#httpClient
+ .get>('https://jsonplaceholder.typicode.com/users')
+ .pipe(tap((data) => (this.#users = data.slice(0, 10)))),
+ )
+ }
+ await this.#usersPromise
+ }
+
+ fetchUsers({ filterBy, sortBy }: { filterBy?: string; sortBy?: UsersSortBy } = {}) {
+ return loaderDelayFn(() =>
+ this.ensureUsers().then(() => {
+ let usersDraft = this.#users
+
+ if (filterBy) {
+ usersDraft = usersDraft.filter((user) =>
+ user.name.toLowerCase().includes(filterBy.toLowerCase()),
+ )
+ }
+
+ if (sortBy) {
+ usersDraft = usersDraft.sort((a, b) => {
+ return a[sortBy] > b[sortBy] ? 1 : -1
+ })
+ }
+
+ return usersDraft
+ }),
+ )
+ }
+
+ fetchUserById(id: number) {
+ return loaderDelayFn(() =>
+ this.ensureUsers().then(() => this.#users.find((user) => user.id === id)),
+ )
+ }
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class RandomService {
+ fetchRandomNumber() {
+ return loaderDelayFn(() => {
+ return Math.random()
+ })
+ }
+}
diff --git a/examples/angular/kitchen-sink/src/styles.css b/examples/angular/kitchen-sink/src/styles.css
new file mode 100644
index 00000000000..37a1064738a
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/styles.css
@@ -0,0 +1,21 @@
+@import 'tailwindcss';
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+html {
+ color-scheme: light dark;
+}
+* {
+ @apply border-gray-200 dark:border-gray-800;
+}
+body {
+ @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
+}
diff --git a/examples/angular/kitchen-sink/src/utils.ts b/examples/angular/kitchen-sink/src/utils.ts
new file mode 100644
index 00000000000..f2e18d61f68
--- /dev/null
+++ b/examples/angular/kitchen-sink/src/utils.ts
@@ -0,0 +1,29 @@
+export async function loaderDelayFn(fn: (...args: Array) => Promise | T) {
+ const delay = Number(sessionStorage.getItem('loaderDelay') ?? 0)
+ const delayPromise = new Promise((r) => setTimeout(r, delay))
+
+ await delayPromise
+ const res = await fn()
+
+ return res
+}
+
+export async function actionDelayFn(fn: (...args: Array) => Promise | T) {
+ const delay = Number(sessionStorage.getItem('actionDelay') ?? 0)
+ await new Promise((r) => setTimeout(r, delay))
+ return fn()
+}
+
+export function shuffle(arr: Array): Array {
+ let i = arr.length
+ if (i == 0) return arr
+ const copy = [...arr]
+ while (--i) {
+ const j = Math.floor(Math.random() * (i + 1))
+ const a = copy[i]
+ const b = copy[j]
+ copy[i] = b!
+ copy[j] = a!
+ }
+ return copy
+}
diff --git a/examples/angular/kitchen-sink/tsconfig.app.json b/examples/angular/kitchen-sink/tsconfig.app.json
new file mode 100644
index 00000000000..c1e0ce8c0e1
--- /dev/null
+++ b/examples/angular/kitchen-sink/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.d.ts", "src/**/*.ts"]
+}
diff --git a/examples/angular/kitchen-sink/tsconfig.json b/examples/angular/kitchen-sink/tsconfig.json
new file mode 100644
index 00000000000..0641f84cf8f
--- /dev/null
+++ b/examples/angular/kitchen-sink/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "useDefineForClassFields": false,
+ "lib": ["ES2022", "dom"]
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/examples/angular/kitchen-sink/tsconfig.spec.json b/examples/angular/kitchen-sink/tsconfig.spec.json
new file mode 100644
index 00000000000..de0ca57967d
--- /dev/null
+++ b/examples/angular/kitchen-sink/tsconfig.spec.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": ["vitest/globals", "node"]
+ },
+ "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
+}
diff --git a/packages/angular-router-devtools/README.md b/packages/angular-router-devtools/README.md
new file mode 100644
index 00000000000..9c00f6cde94
--- /dev/null
+++ b/packages/angular-router-devtools/README.md
@@ -0,0 +1,7 @@
+# TanStack Angular Router Devtools
+
+
+
+🤖 Devtools for TanStack Angular Router!
+
+## Visit [tanstack.com/router](https://tanstack.com/router) for docs, guides, API and more!
diff --git a/packages/angular-router-devtools/eslint.config.js b/packages/angular-router-devtools/eslint.config.js
new file mode 100644
index 00000000000..62a478577a3
--- /dev/null
+++ b/packages/angular-router-devtools/eslint.config.js
@@ -0,0 +1,10 @@
+// @ts-check
+
+import rootConfig from '../../eslint.config.js'
+
+export default [
+ ...rootConfig,
+ {
+ files: ['**/*.{ts,tsx}'],
+ },
+]
diff --git a/packages/angular-router-devtools/ng-package.json b/packages/angular-router-devtools/ng-package.json
new file mode 100644
index 00000000000..6c84af60934
--- /dev/null
+++ b/packages/angular-router-devtools/ng-package.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "./dist",
+ "lib": {
+ "entryFile": "src/index.ts"
+ },
+ "allowedNonPeerDependencies": ["@tanstack/router-devtools-core"]
+}
+
diff --git a/packages/angular-router-devtools/package.json b/packages/angular-router-devtools/package.json
new file mode 100644
index 00000000000..60b6cd1fa8f
--- /dev/null
+++ b/packages/angular-router-devtools/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@tanstack/angular-router-devtools",
+ "version": "1.142.11",
+ "description": "Modern and scalable routing for Angular applications",
+ "author": "Tanner Linsley",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/router.git",
+ "directory": "packages/angular-router-devtools"
+ },
+ "homepage": "https://tanstack.com/router",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "keywords": [
+ "angular",
+ "location",
+ "router",
+ "routing",
+ "async",
+ "async router",
+ "typescript"
+ ],
+ "scripts": {
+ "clean": "rimraf ./dist && rimraf ./coverage",
+ "test:eslint": "eslint ./src",
+ "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"",
+ "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts59": "tsc -p tsconfig.legacy.json",
+ "test:build": "publint --strict && attw --ignore-rules no-resolution cjs-resolves-to-esm --pack .",
+ "build": "ng-packagr -p ng-package.json -c tsconfig.lib.json"
+ },
+ "type": "module",
+ "sideEffects": false,
+ "types": "./dist/types/tanstack-angular-router-devtools.d.ts",
+ "exports": {
+ "./package.json": {
+ "default": "./package.json"
+ },
+ ".": {
+ "types": "./dist/types/tanstack-angular-router-devtools.d.ts",
+ "require": null,
+ "default": "./dist/fesm2022/tanstack-angular-router-devtools.mjs"
+ }
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "engines": {
+ "node": ">=12"
+ },
+ "dependencies": {
+ "@tanstack/router-devtools-core": "workspace:*",
+ "tslib": "^2.3.0"
+ },
+ "devDependencies": {
+ "@analogjs/vite-plugin-angular": "^2.0.0",
+ "@analogjs/vitest-angular": "^2.2.1",
+ "@angular/compiler": "21.0.4",
+ "@angular/compiler-cli": "21.0.4",
+ "ng-packagr": "^21.0.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "21.0.4",
+ "@tanstack/angular-router-experimental": "workspace:^",
+ "@tanstack/router-core": "workspace:^"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/router-core": {
+ "optional": true
+ }
+ }
+}
diff --git a/packages/angular-router-devtools/src/index.ts b/packages/angular-router-devtools/src/index.ts
new file mode 100644
index 00000000000..80d6e6aae98
--- /dev/null
+++ b/packages/angular-router-devtools/src/index.ts
@@ -0,0 +1,37 @@
+import { Component } from '@angular/core'
+import * as Devtools from './tanstack-router-devtools'
+import * as DevtoolsPanel from './tanstack-router-devtools-panel'
+
+// No-op component for production
+@Component({
+ selector: 'router-devtools',
+ template: '',
+ standalone: true,
+})
+class NoOpTanStackRouterDevtools {}
+
+@Component({
+ selector: 'router-devtools-panel',
+ template: '',
+ standalone: true,
+})
+class NoOpTanStackRouterDevtoolsPanel {}
+
+export const TanStackRouterDevtools: typeof Devtools.TanStackRouterDevtools =
+ process.env.NODE_ENV !== 'development'
+ ? (NoOpTanStackRouterDevtools as any)
+ : Devtools.TanStackRouterDevtools
+
+export const TanStackRouterDevtoolsInProd: typeof Devtools.TanStackRouterDevtools =
+ Devtools.TanStackRouterDevtools
+
+export const TanStackRouterDevtoolsPanel: typeof DevtoolsPanel.TanStackRouterDevtoolsPanel =
+ process.env.NODE_ENV !== 'development'
+ ? (NoOpTanStackRouterDevtoolsPanel as any)
+ : DevtoolsPanel.TanStackRouterDevtoolsPanel
+
+export const TanStackRouterDevtoolsPanelInProd: typeof DevtoolsPanel.TanStackRouterDevtoolsPanel =
+ DevtoolsPanel.TanStackRouterDevtoolsPanel
+
+export type { TanStackRouterDevtoolsOptions } from './tanstack-router-devtools'
+export type { TanStackRouterDevtoolsPanelOptions } from './tanstack-router-devtools-panel'
diff --git a/packages/angular-router-devtools/src/tanstack-router-devtools-panel.ts b/packages/angular-router-devtools/src/tanstack-router-devtools-panel.ts
new file mode 100644
index 00000000000..62e72991518
--- /dev/null
+++ b/packages/angular-router-devtools/src/tanstack-router-devtools-panel.ts
@@ -0,0 +1,120 @@
+import {
+ Component,
+ DestroyRef,
+ ElementRef,
+ EnvironmentInjector,
+ afterNextRender,
+ computed,
+ effect,
+ inject,
+ input,
+ runInInjectionContext,
+} from '@angular/core'
+import { TanStackRouterDevtoolsPanelCore } from '@tanstack/router-devtools-core'
+import { injectRouter } from '@tanstack/angular-router-experimental'
+import { injectLazyRouterState } from './utils'
+import type { AnyRouter } from '@tanstack/router-core'
+
+export interface TanStackRouterDevtoolsPanelOptions {
+ /**
+ * The standard React style object used to style a component with inline styles
+ */
+ style?: Record
+ /**
+ * The standard React class property used to style a component with classes
+ */
+ className?: string
+ /**
+ * A boolean variable indicating whether the panel is open or closed
+ */
+ isOpen?: boolean
+ /**
+ * A function that toggles the open and close state of the panel
+ */
+ setIsOpen?: (isOpen: boolean) => void
+ /**
+ * Handles the opening and closing the devtools panel
+ */
+ handleDragStart?: (e: any) => void
+ /**
+ * The router instance to use for the devtools, infered in the injector context if no provided.
+ */
+ router?: AnyRouter
+ /**
+ * Use this to attach the devtool's styles to specific element in the DOM.
+ */
+ shadowDOMTarget?: ShadowRoot
+}
+
+@Component({
+ selector: 'tanstack-router-devtools-panel',
+ template: '',
+ styles: `
+ :host {
+ display: block;
+ }
+ `,
+})
+export class TanStackRouterDevtoolsPanel {
+ style = input()
+ className = input()
+ isOpen = input()
+ setIsOpen = input()
+ handleDragStart =
+ input()
+ inputRouter = input(undefined, {
+ alias: 'router',
+ })
+ shadowDOMTarget =
+ input()
+
+ private elementRef = inject(ElementRef)
+
+ private contextRouter = injectRouter({ warn: false })
+ private router = computed(() => this.inputRouter() ?? this.contextRouter)
+ private routerState = injectLazyRouterState(this.router)
+
+ private injector = inject(EnvironmentInjector)
+
+ ngOnInit() {
+ // Since inputs are not available before component initialization,
+ // we attach every effect and derived signal to the ngOnInit lifecycle hook
+ runInInjectionContext(this.injector, () => {
+ const devtoolsPanel = new TanStackRouterDevtoolsPanelCore({
+ style: this.style(),
+ className: this.className(),
+ isOpen: this.isOpen(),
+ setIsOpen: this.setIsOpen(),
+ handleDragStart: this.handleDragStart(),
+ router: this.router(),
+ routerState: this.routerState(),
+ })
+
+ effect(() => {
+ devtoolsPanel.setRouter(this.router())
+ })
+
+ effect(() => {
+ devtoolsPanel.setRouterState(this.routerState())
+ })
+
+ effect(() => {
+ devtoolsPanel.setOptions({
+ style: this.style(),
+ className: this.className(),
+ isOpen: this.isOpen(),
+ setIsOpen: this.setIsOpen(),
+ handleDragStart: this.handleDragStart(),
+ })
+ })
+
+ afterNextRender(() => {
+ devtoolsPanel.mount(this.elementRef.nativeElement)
+ })
+
+ inject(DestroyRef).onDestroy(() => {
+ devtoolsPanel.unmount()
+ })
+ })
+ }
+}
diff --git a/packages/angular-router-devtools/src/tanstack-router-devtools.ts b/packages/angular-router-devtools/src/tanstack-router-devtools.ts
new file mode 100644
index 00000000000..2437041e53c
--- /dev/null
+++ b/packages/angular-router-devtools/src/tanstack-router-devtools.ts
@@ -0,0 +1,126 @@
+import {
+ Component,
+ DestroyRef,
+ ElementRef,
+ EnvironmentInjector,
+ OnInit,
+ afterNextRender,
+ computed,
+ effect,
+ inject,
+ input, runInInjectionContext
+} from '@angular/core'
+import { TanStackRouterDevtoolsCore } from '@tanstack/router-devtools-core'
+import { injectRouter } from '@tanstack/angular-router-experimental'
+import { injectLazyRouterState } from './utils'
+import type { AnyRouter } from '@tanstack/router-core'
+
+export interface TanStackRouterDevtoolsOptions {
+ /**
+ * Set this true if you want the dev tools to default to being open
+ */
+ initialIsOpen?: boolean
+ /**
+ * Use this to add props to the panel. For example, you can add className, style (merge and override default style), etc.
+ */
+ panelProps?: Record
+ /**
+ * Use this to add props to the close button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc.
+ */
+ closeButtonProps?: Record
+ /**
+ * Use this to add props to the toggle button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc.
+ */
+ toggleButtonProps?: Record
+ /**
+ * The position of the TanStack Router logo to open and close the devtools panel.
+ * Defaults to 'bottom-left'.
+ */
+ position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
+ /**
+ * Use this to render the devtools inside a different type of container element for a11y purposes.
+ * Any string which corresponds to a valid intrinsic JSX element is allowed.
+ * Defaults to 'footer'.
+ */
+ containerElement?: string | any
+ /**
+ * The router instance to use for the devtools, infered in the injector context if no provided.
+ */
+ router?: AnyRouter
+ /**
+ * Use this to attach the devtool's styles to specific element in the DOM.
+ */
+ shadowDOMTarget?: ShadowRoot
+}
+
+@Component({
+ selector: 'router-devtools',
+ template: '',
+})
+export class TanStackRouterDevtools implements OnInit {
+ initialIsOpen = input()
+ panelProps = input()
+ closeButtonProps = input()
+ toggleButtonProps =
+ input()
+ position = input()
+ containerElement = input()
+ inputRouter = input(undefined, {
+ alias: 'router',
+ })
+ shadowDOMTarget = input()
+
+ private elementRef = inject(ElementRef)
+
+ private contextRouter = injectRouter({ warn: false })
+ private router = computed(() => this.inputRouter() ?? this.contextRouter)
+ private routerState = injectLazyRouterState(this.router)
+
+ private injector = inject(EnvironmentInjector)
+
+ ngOnInit() {
+ // Since inputs are not available before component initialization,
+ // we attach every effect and derived signal to the ngOnInit lifecycle hook
+ runInInjectionContext(this.injector, () => {
+ const devtools = new TanStackRouterDevtoolsCore({
+ initialIsOpen: this.initialIsOpen(),
+ panelProps: this.panelProps(),
+ closeButtonProps: this.closeButtonProps(),
+ toggleButtonProps: this.toggleButtonProps(),
+ position: this.position(),
+ containerElement: this.containerElement(),
+ shadowDOMTarget: this.shadowDOMTarget(),
+ router: this.router(),
+ routerState: this.routerState(),
+ })
+
+ effect(() => {
+ devtools.setRouter(this.router())
+ })
+
+ effect(() => {
+ devtools.setRouterState(this.routerState())
+ })
+
+ effect(() => {
+ devtools.setOptions({
+ initialIsOpen: this.initialIsOpen(),
+ panelProps: this.panelProps(),
+ closeButtonProps: this.closeButtonProps(),
+ toggleButtonProps: this.toggleButtonProps(),
+ position: this.position(),
+ containerElement: this.containerElement(),
+ shadowDOMTarget: this.shadowDOMTarget(),
+ })
+ })
+
+ afterNextRender(() => {
+ devtools.mount(this.elementRef.nativeElement)
+ })
+
+ inject(DestroyRef).onDestroy(() => {
+ devtools.unmount()
+ })
+ })
+ }
+}
diff --git a/packages/angular-router-devtools/src/utils.ts b/packages/angular-router-devtools/src/utils.ts
new file mode 100644
index 00000000000..2c2f032962d
--- /dev/null
+++ b/packages/angular-router-devtools/src/utils.ts
@@ -0,0 +1,25 @@
+import { computed, effect, signal } from '@angular/core'
+import type { Signal } from '@angular/core';
+import type { AnyRouter, RouterState } from '@tanstack/router-core'
+
+/**
+ * Subscribe to a signal state where the router is a
+ * signal that can't be read before initialization
+ * @param routerSignal - The signal that contains the router
+ * @returns - A signal that contains the router state
+ */
+export function injectLazyRouterState(
+ routerSignal: Signal,
+): Signal {
+ const routerState = signal(undefined)
+
+ effect((onCleanup) => {
+ const router = routerSignal()
+ const unsubscribe = router.__store.subscribe((state) => {
+ routerState.set(state.currentVal)
+ })
+ onCleanup(() => unsubscribe())
+ })
+
+ return computed(() => routerState() ?? routerSignal().__store.state)
+}
diff --git a/packages/angular-router-devtools/tsconfig.json b/packages/angular-router-devtools/tsconfig.json
new file mode 100644
index 00000000000..8c6dc36cd86
--- /dev/null
+++ b/packages/angular-router-devtools/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
+ "target": "ES2022",
+ "useDefineForClassFields": false
+ },
+ "include": ["src", "tests", "vitest.config.ts", "eslint.config.js"]
+}
diff --git a/packages/angular-router-devtools/tsconfig.legacy.json b/packages/angular-router-devtools/tsconfig.legacy.json
new file mode 100644
index 00000000000..b90fc83e04c
--- /dev/null
+++ b/packages/angular-router-devtools/tsconfig.legacy.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src"]
+}
diff --git a/packages/angular-router-devtools/tsconfig.lib.json b/packages/angular-router-devtools/tsconfig.lib.json
new file mode 100644
index 00000000000..c879a0eb2f9
--- /dev/null
+++ b/packages/angular-router-devtools/tsconfig.lib.json
@@ -0,0 +1,24 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": ["node"],
+ "noEmit": false,
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler"
+ },
+ "angularCompilerOptions": {
+ "compilationMode": "partial",
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": false,
+ "strictTemplates": true,
+ "skipMetadataEmit": false
+ },
+ "exclude": ["**/*.spec.ts", "tests/**/*", "**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
+
diff --git a/packages/angular-router-devtools/vitest.config.ts b/packages/angular-router-devtools/vitest.config.ts
new file mode 100644
index 00000000000..acafa9d65f9
--- /dev/null
+++ b/packages/angular-router-devtools/vitest.config.ts
@@ -0,0 +1,18 @@
+///
+
+import angular from '@analogjs/vite-plugin-angular'
+import { defineConfig } from 'vite'
+import packageJson from './package.json'
+
+export default defineConfig({
+ plugins: [angular()],
+ test: {
+ name: packageJson.name,
+ dir: './tests',
+ watch: false,
+ environment: 'jsdom',
+ typecheck: { enabled: true },
+ passWithNoTests: true,
+ },
+})
+
diff --git a/packages/angular-router-experimental/README.md b/packages/angular-router-experimental/README.md
new file mode 100644
index 00000000000..a4fef86dd0a
--- /dev/null
+++ b/packages/angular-router-experimental/README.md
@@ -0,0 +1,15 @@
+# TanStack Angular Router
+
+
+
+🤖 Type-safe router w/ built-in caching & URL state management for Angular!
+
+> ⚠️ Warning:
+> This Angular Router is experimental!
+> It is subject to breaking changes.
+
+This Angular adapter has the following limitations:
+- No bubling up the loading state or suspense-like features. Configurations like `defaultPendingMs` will not work.
+- No integration with Angular's pending tasks for SSR.
+
+## Visit [tanstack.com/router](https://tanstack.com/router) for docs, guides, API and more!
diff --git a/packages/angular-router-experimental/angular.json b/packages/angular-router-experimental/angular.json
new file mode 100644
index 00000000000..f7695c975d7
--- /dev/null
+++ b/packages/angular-router-experimental/angular.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-router-experimental": {
+ "projectType": "library",
+ "root": ".",
+ "sourceRoot": "src",
+ "prefix": "",
+ "architect": {
+ "test": {
+ "builder": "@angular/build:unit-test",
+ "options": {
+ "tsConfig": "tsconfig.spec.json",
+ "runner": "vitest"
+ }
+ },
+ "build": {
+ "builder": "@angular-devkit/build-angular:ng-packagr",
+ "options": {
+ "project": "ng-package.json"
+ },
+ "configurations": {
+ "production": {
+ "tsConfig": "tsconfig.lib.prod.json"
+ },
+ "development": {
+ "tsConfig": "tsconfig.lib.json"
+ }
+ },
+ "defaultConfiguration": "production"
+ }
+ }
+ }
+ },
+ "cli": {
+ "analytics": false,
+ "cache": { "enabled": false }
+ }
+}
+
diff --git a/packages/angular-router-experimental/eslint.config.js b/packages/angular-router-experimental/eslint.config.js
new file mode 100644
index 00000000000..62a478577a3
--- /dev/null
+++ b/packages/angular-router-experimental/eslint.config.js
@@ -0,0 +1,10 @@
+// @ts-check
+
+import rootConfig from '../../eslint.config.js'
+
+export default [
+ ...rootConfig,
+ {
+ files: ['**/*.{ts,tsx}'],
+ },
+]
diff --git a/packages/angular-router-experimental/experimental/injectRouteErrorHandler.ts b/packages/angular-router-experimental/experimental/injectRouteErrorHandler.ts
new file mode 100644
index 00000000000..ad2a7849b3c
--- /dev/null
+++ b/packages/angular-router-experimental/experimental/injectRouteErrorHandler.ts
@@ -0,0 +1,51 @@
+import { DestroyRef, inject, untracked } from '@angular/core'
+import { injectMatch, injectRouter } from '@tanstack/angular-router-experimental'
+import type { AnyRouter, FromPathOption, RegisteredRouter } from '@tanstack/router-core'
+
+/**
+ * EXPERIMENTAL
+ *
+ * While in other adapters you can use build-in error boundaries,
+ * Angular does not provide any. As an workarraound, we export a function
+ * to simulate an error boundary by changing the router state to show
+ * the error component.
+ *
+ * Note that an equivalent for suspense can't exist since we can't restore
+ * the component state when the promise is resolved as is with other adapters.
+ */
+export function injectRouteErrorHandler<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDefaultFrom extends string = string,
+>(options: { from?: FromPathOption }) {
+ const router = injectRouter()
+ const match = injectMatch({ from: options.from })
+
+ let destroyed = false
+
+ inject(DestroyRef).onDestroy(() => {
+ destroyed = true
+ })
+
+ return {
+ throw: (error: Error) => {
+ if (destroyed) {
+ console.warn(
+ 'Attempted to throw error to route after it has been destroyed',
+ )
+ return
+ }
+
+ const matchId = untracked(match).id
+
+ router.updateMatch(matchId, (match) => {
+ return {
+ ...match,
+ error,
+ status: 'error',
+ isFetching: false,
+ updatedAt: Date.now(),
+ }
+ })
+ },
+ }
+}
diff --git a/packages/angular-router-experimental/experimental/ng-package.json b/packages/angular-router-experimental/experimental/ng-package.json
new file mode 100644
index 00000000000..5cb8e1e46dc
--- /dev/null
+++ b/packages/angular-router-experimental/experimental/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "public_api.ts"
+ }
+}
diff --git a/packages/angular-router-experimental/experimental/public_api.ts b/packages/angular-router-experimental/experimental/public_api.ts
new file mode 100644
index 00000000000..90973d5fefd
--- /dev/null
+++ b/packages/angular-router-experimental/experimental/public_api.ts
@@ -0,0 +1,8 @@
+/**
+ * @experimental
+ *
+ * This entrypoint contains experimental APIs that may change or be removed
+ * in future versions. Use with caution.
+ */
+
+export { injectRouteErrorHandler } from './injectRouteErrorHandler'
diff --git a/packages/angular-router-experimental/ng-package.json b/packages/angular-router-experimental/ng-package.json
new file mode 100644
index 00000000000..3d5f571fbdb
--- /dev/null
+++ b/packages/angular-router-experimental/ng-package.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "./dist",
+ "lib": {
+ "entryFile": "src/index.ts"
+ },
+ "allowedNonPeerDependencies": [
+ "@tanstack/angular-store",
+ "@tanstack/history",
+ "@tanstack/router-core",
+ "tiny-invariant",
+ "tiny-warning",
+ "isbot"
+ ]
+}
+
diff --git a/packages/angular-router-experimental/package.json b/packages/angular-router-experimental/package.json
new file mode 100644
index 00000000000..d4a1120b9b8
--- /dev/null
+++ b/packages/angular-router-experimental/package.json
@@ -0,0 +1,95 @@
+{
+ "name": "@tanstack/angular-router-experimental",
+ "version": "1.142.11",
+ "description": "Modern and scalable routing for Angular applications",
+ "author": "Tanner Linsley",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/router.git",
+ "directory": "packages/angular-router-experimental"
+ },
+ "homepage": "https://tanstack.com/router",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "keywords": [
+ "angular",
+ "location",
+ "router",
+ "routing",
+ "async",
+ "async router",
+ "typescript"
+ ],
+ "scripts": {
+ "clean": "rimraf ./dist && rimraf ./coverage",
+ "test:eslint": "eslint",
+ "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"",
+ "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js -p tsconfig.legacy.json",
+ "test:types:ts59": "tsc -p tsconfig.legacy.json",
+ "test:unit": "vitest",
+ "test:unit:dev": "pnpm run test:unit --watch --hideSkippedTests",
+ "test:perf": "vitest bench",
+ "test:perf:dev": "pnpm run test:perf --watch --hideSkippedTests",
+ "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
+ "build": "ng-packagr -p ng-package.json -c tsconfig.lib.json"
+ },
+ "type": "module",
+ "sideEffects": false,
+ "types": "./dist/types/tanstack-angular-router-experimental.d.ts",
+ "exports": {
+ "./package.json": {
+ "default": "./package.json"
+ },
+ ".": {
+ "types": "./dist/types/tanstack-angular-router-experimental.d.ts",
+ "require": null,
+ "default": "./dist/fesm2022/tanstack-angular-router-experimental.mjs"
+ },
+ "./experimental": {
+ "types": "./dist/types/tanstack-angular-router-experimental-experimental.d.ts",
+ "require": null,
+ "default": "./dist/fesm2022/tanstack-angular-router-experimental-experimental.mjs"
+ }
+ },
+ "files": [
+ "dist",
+ "src",
+ "experimental"
+ ],
+ "engines": {
+ "node": ">=12"
+ },
+ "dependencies": {
+ "@tanstack/angular-store": "^0.8.0",
+ "@tanstack/history": "workspace:*",
+ "@tanstack/router-core": "workspace:*",
+ "isbot": "^5.1.22",
+ "tiny-invariant": "^1.3.3",
+ "tiny-warning": "^1.0.3",
+ "tslib": "^2.3.0"
+ },
+ "devDependencies": {
+ "@analogjs/vite-plugin-angular": "^2.2.1",
+ "@analogjs/vitest-angular": "^2.2.1",
+ "@angular/compiler": "^21.0.0",
+ "@angular/compiler-cli": "^21.0.0",
+ "@angular/platform-browser": "^21.0.0",
+ "@testing-library/angular": "^19.0.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "combinate": "^1.1.11",
+ "jsdom": "^27.4.0",
+ "ng-packagr": "^21.0.0",
+ "vibe-rules": "^0.2.57",
+ "zod": "^3.24.2"
+ },
+ "peerDependencies": {
+ "@angular/core": "^21.0.0"
+ }
+}
diff --git a/packages/angular-router-experimental/src/DefaultNotFound.ts b/packages/angular-router-experimental/src/DefaultNotFound.ts
new file mode 100644
index 00000000000..5741e9c3ab9
--- /dev/null
+++ b/packages/angular-router-experimental/src/DefaultNotFound.ts
@@ -0,0 +1,8 @@
+import * as Angular from '@angular/core'
+
+@Angular.Component({
+ template: `Not found
`,
+ changeDetection: Angular.ChangeDetectionStrategy.OnPush,
+ host: { style: 'display: contents;' },
+})
+export class DefaultNotFoundComponent {}
diff --git a/packages/angular-router-experimental/src/Link.ts b/packages/angular-router-experimental/src/Link.ts
new file mode 100644
index 00000000000..d7d9cf5a69f
--- /dev/null
+++ b/packages/angular-router-experimental/src/Link.ts
@@ -0,0 +1,342 @@
+import * as Angular from '@angular/core'
+import {
+ AnyRouter,
+ LinkOptions as CoreLinkOptions,
+ LinkCurrentTargetElement,
+ RegisteredRouter,
+ RoutePaths,
+ deepEqual,
+ exactPathTest,
+ preloadWarning,
+ removeTrailingSlash,
+} from '@tanstack/router-core'
+import { injectRouterState } from './injectRouterState'
+import { injectRouter } from './injectRouter'
+import { injectIntersectionObserver } from './injectIntersectionObserver'
+
+@Angular.Directive({
+ selector: 'a[link]',
+ exportAs: 'link',
+ standalone: true,
+ host: {
+ '[href]': 'hrefOption()?.href',
+ '(click)': 'handleClick($event)',
+ '(focus)': 'handleFocus()',
+ '(mouseenter)': 'handleEnter($event)',
+ '(mouseover)': 'handleEnter($event)',
+ '(mouseleave)': 'handleLeave($event)',
+ '[attr.target]': 'target()',
+ '[attr.role]': 'disabled() ? "link" : undefined',
+ '[attr.aria-disabled]': 'disabled()',
+ '[attr.data-status]': 'isActive() ? "active" : undefined',
+ '[attr.aria-current]': 'isActive() ? "page" : undefined',
+ '[attr.data-transitioning]':
+ 'isTransitioning() ? "transitioning" : undefined',
+ '[class]': 'isActiveProps()?.class',
+ '[style]': 'isActiveProps()?.style',
+ },
+})
+export class Link<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends RoutePaths | string = string,
+ TTo extends string | undefined = '.',
+ TMaskFrom extends RoutePaths | string = TFrom,
+ TMaskTo extends string = '.',
+> {
+ passiveEvents = injectPasiveEvents(() => ({
+ touchstart: this.handleTouchStart,
+ }))
+
+ options = Angular.input.required<
+ LinkOptions
+ >({ alias: 'link' })
+
+ protected router = injectRouter()
+ protected isTransitioning = Angular.signal(false)
+
+ protected currentSearch = injectRouterState({
+ select: (s) => s.location.searchStr,
+ })
+
+ protected from = Angular.computed(() =>
+ Angular.untracked(() => this.options().from),
+ )
+
+ protected disabled = Angular.computed(() => this._options().disabled ?? false)
+ protected target = Angular.computed(() => this._options().target)
+
+ protected _options = Angular.computed<
+ LinkOptions
+ >(() => {
+ return {
+ ...this.options(),
+ from: this.from(),
+ }
+ })
+
+ protected nextLocation = Angular.computed(() => {
+ this.currentSearch()
+ return this.router.buildLocation(this._options() as any)
+ })
+
+ protected hrefOption = Angular.computed(() => {
+ if (this._options().disabled) {
+ return undefined
+ }
+
+ let href
+ const maskedLocation = this.nextLocation().maskedLocation
+ if (maskedLocation) {
+ href = maskedLocation.url.href
+ } else {
+ href = this.nextLocation().url.href
+ }
+ let external = false
+ if (this.router.origin) {
+ if (href.startsWith(this.router.origin)) {
+ href = this.router.history.createHref(
+ href.replace(this.router.origin, ''),
+ )
+ } else {
+ external = true
+ }
+ }
+ return { href, external }
+ })
+
+ protected externalLink = Angular.computed(() => {
+ const hrefOption = this.hrefOption()
+ if (hrefOption?.external) {
+ return hrefOption.href
+ }
+ try {
+ new URL(this.options()['to'] as any)
+ return this.options()['to']
+ } catch { }
+ return undefined
+ })
+
+ protected preload = Angular.computed(() => {
+ if (this.options()['reloadDocument']) {
+ return false
+ }
+ return this.options()['preload'] ?? this.router.options.defaultPreload
+ })
+
+ protected preloadDelay = Angular.computed(() => {
+ return (
+ this.options()['preloadDelay'] ??
+ this.router.options.defaultPreloadDelay ??
+ 0
+ )
+ })
+
+ protected location = injectRouterState({
+ select: (s) => s.location,
+ })
+
+
+ protected isActiveProps = Angular.computed(() => {
+ const opts = this.options()
+ const isActive = this.isActive()
+ const props = isActive ? opts.activeProps : opts.inactiveProps
+ if (!props || typeof props !== 'object') return undefined
+ return props
+ })
+
+ protected isActive = Angular.computed(() => {
+ if (this.externalLink()) return false
+
+ const options = this.options()
+
+ if (options.activeOptions?.exact) {
+ const testExact = exactPathTest(
+ this.location().pathname,
+ this.nextLocation().pathname,
+ this.router.basepath,
+ )
+ if (!testExact) {
+ return false
+ }
+ } else {
+ const currentPathSplit = removeTrailingSlash(
+ this.location().pathname,
+ this.router.basepath,
+ )
+ const nextPathSplit = removeTrailingSlash(
+ this.nextLocation().pathname,
+ this.router.basepath,
+ )
+
+ const pathIsFuzzyEqual =
+ currentPathSplit.startsWith(nextPathSplit) &&
+ (currentPathSplit.length === nextPathSplit.length ||
+ currentPathSplit[nextPathSplit.length] === '/')
+
+ if (!pathIsFuzzyEqual) {
+ return false
+ }
+ }
+
+ if (options.activeOptions?.includeSearch ?? true) {
+ const searchTest = deepEqual(
+ this.location().search,
+ this.nextLocation().search,
+ {
+ partial: !options.activeOptions?.exact,
+ ignoreUndefined: !options.activeOptions?.explicitUndefined,
+ },
+ )
+ if (!searchTest) {
+ return false
+ }
+ }
+
+ if (options.activeOptions?.includeHash) {
+ return this.location().hash === this.nextLocation().hash
+ }
+ return true
+ })
+
+ protected doPreload = () => {
+ this.router.preloadRoute(this.options() as any).catch((err: any) => {
+ console.warn(err)
+ console.warn(preloadWarning)
+ })
+ }
+
+ protected preloadViewportIoCallback = (
+ entry: IntersectionObserverEntry | undefined,
+ ) => {
+ if (entry?.isIntersecting) {
+ this.doPreload()
+ }
+ }
+
+ private viewportPreloader = injectIntersectionObserver(
+ this.preloadViewportIoCallback,
+ { rootMargin: '100px' },
+ () => !!this._options().disabled || !(this.preload() === 'viewport'),
+ )
+
+ private hasRenderFetched = false
+ private rendererPreloader = Angular.effect(() => {
+ if (this.hasRenderFetched) return
+
+ if (!this._options().disabled && this.preload() === 'render') {
+ this.doPreload()
+ this.hasRenderFetched = true
+ }
+ })
+
+ protected handleClick = (event: MouseEvent) => {
+ const elementTarget = (
+ event.currentTarget as HTMLAnchorElement | SVGAElement
+ ).getAttribute('target')
+ const target = this._options().target
+ const effectiveTarget = target !== undefined ? target : elementTarget
+
+ if (
+ !this._options().disabled &&
+ !isCtrlEvent(event) &&
+ !event.defaultPrevented &&
+ (!effectiveTarget || effectiveTarget === '_self') &&
+ event.button === 0
+ ) {
+ event.preventDefault()
+
+ this.isTransitioning.set(true)
+
+ const unsub = this.router.subscribe('onResolved', () => {
+ unsub()
+ this.isTransitioning.set(false)
+ })
+
+ this.router.navigate(this._options())
+ }
+ }
+
+ protected handleFocus = () => {
+ if (this._options().disabled) return
+ if (this.preload()) {
+ this.doPreload()
+ }
+ }
+
+ protected handleTouchStart = () => {
+ if (this._options().disabled) return
+ if (this.preload()) {
+ this.doPreload()
+ }
+ }
+
+ protected handleEnter = (event: MouseEvent) => {
+ if (this._options().disabled) return
+ const eventTarget = (event.currentTarget || {}) as LinkCurrentTargetElement
+
+ if (this.preload()) {
+ if (eventTarget.preloadTimeout) {
+ return
+ }
+
+ eventTarget.preloadTimeout = setTimeout(() => {
+ eventTarget.preloadTimeout = null
+ this.doPreload()
+ }, this.preloadDelay())
+ }
+ }
+
+ protected handleLeave = (event: MouseEvent) => {
+ if (this._options().disabled) return
+ const eventTarget = (event.currentTarget || {}) as LinkCurrentTargetElement
+
+ if (eventTarget.preloadTimeout) {
+ clearTimeout(eventTarget.preloadTimeout)
+ eventTarget.preloadTimeout = null
+ }
+ }
+}
+
+interface ActiveLinkProps {
+ class?: string
+ style?: string
+}
+
+interface ActiveLinkOptionProps {
+ activeProps?: ActiveLinkProps
+ inactiveProps?: ActiveLinkProps
+}
+
+export type LinkOptions<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends RoutePaths | string = string,
+ TTo extends string | undefined = '.',
+ TMaskFrom extends RoutePaths | string = TFrom,
+ TMaskTo extends string = '.',
+> = CoreLinkOptions &
+ ActiveLinkOptionProps
+
+function isCtrlEvent(e: MouseEvent) {
+ return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
+}
+
+// Angular does not provide by default passive events listeners
+// to some events like React, and does not support a pasive options
+// in the template, so we attach the pasive events manually here
+
+type PassiveEvents = {
+ touchstart: (event: TouchEvent) => void
+}
+
+function injectPasiveEvents(passiveEvents: () => PassiveEvents) {
+ const element = Angular.inject(Angular.ElementRef).nativeElement
+ const renderer = Angular.inject(Angular.Renderer2)
+
+ Angular.afterNextRender(() => {
+ for (const [event, handler] of Object.entries(passiveEvents())) {
+ renderer.listen(element, event, handler, {
+ passive: true,
+ })
+ }
+ })
+}
diff --git a/packages/angular-router-experimental/src/Match.ts b/packages/angular-router-experimental/src/Match.ts
new file mode 100644
index 00000000000..d972b904f08
--- /dev/null
+++ b/packages/angular-router-experimental/src/Match.ts
@@ -0,0 +1,278 @@
+import * as Angular from '@angular/core'
+import {
+ AnyRoute,
+ AnyRouter,
+ getLocationChangeInfo,
+ rootRouteId,
+} from '@tanstack/router-core'
+import warning from 'tiny-warning'
+import { injectRouter } from './injectRouter'
+import { injectRouterState } from './injectRouterState'
+import { DefaultNotFoundComponent } from './DefaultNotFound'
+import { MATCH_ID_INJECTOR_TOKEN } from './matchInjectorToken'
+import { injectRender } from './renderer/injectRender'
+import { ERROR_STATE_INJECTOR_TOKEN } from './injectErrorState'
+import { injectIsCatchingError } from './renderer/injectIsCatchingError'
+
+// In Angular, there is not concept of suspense or error boundaries,
+// so we dont' need to wrap the inner content of the match.
+// So in this adapter, we use derived state instead of state boundaries.
+
+// Equivalent to the OnRendered component.
+function injectOnRendered({
+ parentRouteIsRoot,
+}: {
+ parentRouteIsRoot: Angular.Signal
+}) {
+ const router = injectRouter({ warn: false })
+
+ const location = injectRouterState({
+ select: (s) => s.resolvedLocation?.state.key,
+ })
+
+ Angular.effect(() => {
+ if (!parentRouteIsRoot()) return
+ location() // Track location
+
+ router.emit({
+ type: 'onRendered',
+ ...getLocationChangeInfo(router.state),
+ })
+ })
+}
+
+@Angular.Component({
+ selector: 'router-match',
+ template: '',
+ standalone: true,
+ host: {
+ '[attr.data-matchId]': 'matchId()',
+ },
+})
+export class RouteMatch {
+ matchId = Angular.input.required()
+
+ router = injectRouter()
+
+ matches = injectRouterState({
+ select: (s) => s.matches,
+ })
+
+ matchData = Angular.computed(() => {
+ const matchIndex = this.matches().findIndex((d) => d.id === this.matchId())
+ if (matchIndex === -1) return null
+
+ const match = this.matches()[matchIndex]!
+ const parentRouteId =
+ matchIndex > 0 ? this.matches()[matchIndex - 1]?.routeId : null
+
+ const routeId = match.routeId
+ const route = this.router.routesById[routeId] as AnyRoute
+ const remountFn =
+ route.options.remountDeps ?? this.router.options.defaultRemountDeps
+
+ const remountDeps = remountFn?.({
+ routeId,
+ loaderDeps: match.loaderDeps,
+ params: match._strictParams,
+ search: match._strictSearch,
+ })
+ const key = remountDeps ? JSON.stringify(remountDeps) : undefined
+
+ return {
+ key,
+ route,
+ match,
+ parentRouteId,
+ }
+ })
+
+ isFistRouteInRouteTree = Angular.computed(
+ () => this.matchData()?.parentRouteId === rootRouteId,
+ )
+
+ resolvedNoSsr = Angular.computed(() => {
+ const match = this.matchData()?.match
+ if (!match) return true
+ return match.ssr === false || match.ssr === 'data-only'
+ })
+
+ shouldClientOnly = Angular.computed(() => {
+ const match = this.matchData()?.match
+ if (!match) return true
+ return this.resolvedNoSsr() || !!match._displayPending
+ })
+
+
+ parentRouteIdSignal = Angular.computed(
+ () => this.matchData()?.parentRouteId ?? '',
+ )
+ rootRouteIdSignal = Angular.computed(() => rootRouteId)
+
+ onRendered = injectOnRendered({
+ parentRouteIsRoot: Angular.computed(
+ () => this.parentRouteIdSignal() === rootRouteId,
+ ),
+ })
+
+ isCatchingError = injectIsCatchingError({
+ matchId: this.matchId,
+ })
+
+ render = injectRender(() => {
+ const matchData = this.matchData()
+ if (!matchData) return null
+
+ if (this.shouldClientOnly() && this.router.isServer) {
+ return null
+ }
+
+ const { match, route } = matchData
+
+ if (match.status === 'notFound') {
+ const NotFoundComponent = getNotFoundComponent(this.router, route)
+
+ return {
+ component: NotFoundComponent,
+ }
+ } else if (match.status === 'error' || this.isCatchingError()) {
+ const RouteErrorComponent =
+ getComponent(route.options.errorComponent) ??
+ getComponent(this.router.options.defaultErrorComponent)
+
+ return {
+ component: RouteErrorComponent || null,
+ providers: [
+ {
+ provide: ERROR_STATE_INJECTOR_TOKEN,
+ useValue: {
+ error: match.error,
+ reset: () => {
+ this.router.invalidate()
+ },
+ info: { componentStack: '' },
+ },
+ },
+ ],
+ }
+ } else if (
+ match.status === 'redirected' ||
+ match.status === 'pending'
+ ) {
+ const PendingComponent =
+ getComponent(route.options.pendingComponent) ??
+ getComponent(this.router.options.defaultPendingComponent)
+
+ return {
+ component: PendingComponent,
+ }
+ } else {
+ const Component =
+ getComponent(route.options.component) ??
+ getComponent(this.router.options.defaultComponent) ??
+ Outlet
+
+ const key = matchData.key
+
+ return {
+ key,
+ component: Component,
+ providers: [
+ {
+ provide: MATCH_ID_INJECTOR_TOKEN,
+ useValue: this.matchId as Angular.Signal,
+ },
+ ],
+ }
+ }
+
+ })
+}
+
+@Angular.Component({
+ selector: 'outlet',
+ template: '',
+ standalone: true,
+})
+export class Outlet {
+ router = injectRouter()
+ matchId = Angular.inject(MATCH_ID_INJECTOR_TOKEN)
+
+ routeId = injectRouterState({
+ select: (s) =>
+ s.matches.find((d) => d.id === this.matchId())?.routeId as string,
+ })
+
+ route = Angular.computed(
+ () => this.router.routesById[this.routeId()] as AnyRoute,
+ )
+
+ parentGlobalNotFound = injectRouterState({
+ select: (s) => {
+ const matches = s.matches
+ const parentMatch = matches.find((d) => d.id === this.matchId())
+ if (!parentMatch) return false
+ return parentMatch.globalNotFound
+ },
+ })
+
+ childMatchId = injectRouterState({
+ select: (s) => {
+ const matches = s.matches
+ const index = matches.findIndex((d) => d.id === this.matchId())
+ const child = matches[index + 1]
+ if (!child) return null
+
+ return child.id
+ },
+ })
+
+ render = injectRender(() => {
+ if (this.parentGlobalNotFound()) {
+ // Render not found with warning
+ const NotFoundComponent = getNotFoundComponent(this.router, this.route())
+ return { component: NotFoundComponent }
+ }
+ const childMatchId = this.childMatchId()
+
+ if (!childMatchId) {
+ // Do not render anything
+ return null
+ }
+
+ return {
+ component: RouteMatch,
+ inputs: {
+ matchId: () => this.childMatchId(),
+ },
+ }
+ })
+}
+
+function getNotFoundComponent(router: AnyRouter, route: AnyRoute) {
+ const NotFoundComponent =
+ getComponent(route.options.notFoundComponent) ??
+ getComponent(router.options.defaultNotFoundComponent)
+
+ if (NotFoundComponent) {
+ return NotFoundComponent
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ warning(
+ route.options.notFoundComponent,
+ `A notFoundError was encountered on the route with ID "${route.id}", but a notFoundComponent option was not configured, nor was a router level defaultNotFoundComponent configured. Consider configuring at least one of these to avoid TanStack Router's overly generic defaultNotFoundComponent (Page not found
)`,
+ )
+ }
+
+ return DefaultNotFoundComponent
+}
+
+type CalledIfFunction = T extends (...args: Array) => any ? ReturnType : T
+
+function getComponent(routeComponent: T): CalledIfFunction {
+ if (typeof routeComponent === 'function') {
+ return routeComponent()
+ }
+ return routeComponent as any
+}
diff --git a/packages/angular-router-experimental/src/Matches.ts b/packages/angular-router-experimental/src/Matches.ts
new file mode 100644
index 00000000000..2c783f8e39e
--- /dev/null
+++ b/packages/angular-router-experimental/src/Matches.ts
@@ -0,0 +1,33 @@
+import * as Angular from '@angular/core'
+import { injectRouterState } from './injectRouterState'
+import { injectRender } from './renderer/injectRender'
+import { RouteMatch } from './Match'
+import { injectTransitionerSetup } from './transitioner'
+
+@Angular.Component({
+ selector: 'router-matches',
+ template: '',
+ standalone: true,
+})
+export class Matches {
+ private matchId = injectRouterState({
+ select: (s) => s.matches[0]?.id,
+ })
+
+ transitioner = injectTransitionerSetup()
+
+ render = injectRender(() => {
+ const matchId = this.matchId()
+
+ if (!matchId) {
+ return null
+ }
+
+ return {
+ component: RouteMatch,
+ inputs: {
+ matchId: () => matchId,
+ },
+ }
+ })
+}
diff --git a/packages/angular-router-experimental/src/RouterProvider.ts b/packages/angular-router-experimental/src/RouterProvider.ts
new file mode 100644
index 00000000000..8263c2049be
--- /dev/null
+++ b/packages/angular-router-experimental/src/RouterProvider.ts
@@ -0,0 +1,75 @@
+import * as Angular from '@angular/core'
+import {
+ AnyRouter,
+ RegisteredRouter,
+ RouterOptions,
+} from '@tanstack/router-core'
+import { injectRender } from './renderer/injectRender'
+import { Matches } from './Matches'
+import { getRouterInjectionKey } from './routerInjectionToken'
+
+@Angular.Component({
+ selector: 'router-provider',
+ template: '',
+ standalone: true,
+})
+export class RouterProvider {
+ context: Angular.InputSignal['context']> =
+ Angular.input['context']>({})
+
+ options: Angular.InputSignal<
+ Omit, 'router' | 'context'>
+ > = Angular.input, 'router' | 'context'>>({})
+
+ router = Angular.input.required()
+
+ updateRouter = Angular.effect(() => {
+ // This effect will run before we render
+ this.router().update({
+ ...this.router().options,
+ ...this.options(),
+ context: {
+ ...this.router().options.context,
+ ...this.context(),
+ },
+ })
+ })
+
+ render = injectRender(() => {
+ const router = Angular.untracked(this.router)
+ return {
+ component: Matches,
+ providers: [
+ {
+ provide: getRouterInjectionKey(),
+ useValue: router,
+ },
+ ],
+ }
+ })
+}
+
+type RouterInputs<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDehydrated extends Record = Record,
+> = Omit<
+ RouterOptions<
+ TRouter['routeTree'],
+ NonNullable,
+ false,
+ TRouter['history'],
+ TDehydrated
+ >,
+ 'context'
+> & {
+ router: TRouter
+ context?: Partial<
+ RouterOptions<
+ TRouter['routeTree'],
+ NonNullable,
+ false,
+ TRouter['history'],
+ TDehydrated
+ >['context']
+ >
+}
diff --git a/packages/angular-router-experimental/src/fileRoute.ts b/packages/angular-router-experimental/src/fileRoute.ts
new file mode 100644
index 00000000000..9bf5388a4ba
--- /dev/null
+++ b/packages/angular-router-experimental/src/fileRoute.ts
@@ -0,0 +1,246 @@
+
+import { createRoute } from './route'
+import { injectLoaderData } from './injectLoaderData'
+import { injectLoaderDeps } from './injectLoaderDeps'
+import { injectMatch } from './injectMatch'
+import { injectNavigate } from './injectNavigate'
+import { injectParams } from './injectParams'
+import { injectRouter } from './injectRouter'
+import { injectSearch } from './injectSearch'
+import type {
+ AnyContext,
+ AnyRoute,
+ AnyRouter,
+ ConstrainLiteral,
+ FileBaseRouteOptions,
+ FileRoutesByPath,
+ LazyRouteOptions,
+ Register,
+ RegisteredRouter,
+ ResolveParams,
+ Route,
+ RouteById,
+ RouteConstraints,
+ RouteIds,
+ UpdatableRouteOptions,
+} from '@tanstack/router-core'
+import type { InjectLoaderDataRoute } from './injectLoaderData'
+import type { InjectLoaderDepsRoute } from './injectLoaderDeps'
+import type { InjectMatchRoute } from './injectMatch'
+import type { InjectNavigateResult } from './injectNavigate'
+import type { InjectParamsRoute } from './injectParams'
+import type { InjectRouteContextRoute } from './injectRouteContext'
+import type { InjectSearchRoute } from './injectSearch'
+
+export function createFileRoute<
+ TFilePath extends keyof FileRoutesByPath,
+ TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]['parentRoute'],
+ TId extends RouteConstraints['TId'] = FileRoutesByPath[TFilePath]['id'],
+ TPath extends RouteConstraints['TPath'] = FileRoutesByPath[TFilePath]['path'],
+ TFullPath extends
+ RouteConstraints['TFullPath'] = FileRoutesByPath[TFilePath]['fullPath'],
+>(
+ path?: TFilePath,
+): InternalFileRouteFactory<
+ TFilePath,
+ TParentRoute,
+ TId,
+ TPath,
+ TFullPath
+>['createRoute'] {
+ if (typeof path === 'object') {
+ return new InternalFileRouteFactory<
+ TFilePath,
+ TParentRoute,
+ TId,
+ TPath,
+ TFullPath
+ >().createRoute(path) as any
+ }
+ return new InternalFileRouteFactory<
+ TFilePath,
+ TParentRoute,
+ TId,
+ TPath,
+ TFullPath
+ >().createRoute
+}
+
+class InternalFileRouteFactory<
+ TFilePath extends keyof FileRoutesByPath,
+ TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]['parentRoute'],
+ TId extends RouteConstraints['TId'] = FileRoutesByPath[TFilePath]['id'],
+ TPath extends RouteConstraints['TPath'] = FileRoutesByPath[TFilePath]['path'],
+ TFullPath extends
+ RouteConstraints['TFullPath'] = FileRoutesByPath[TFilePath]['fullPath'],
+> {
+ createRoute = <
+ TRegister = Register,
+ TSearchValidator = undefined,
+ TParams = ResolveParams,
+ TRouteContextFn = AnyContext,
+ TBeforeLoadFn = AnyContext,
+ TLoaderDeps extends Record = {},
+ TLoaderFn = undefined,
+ TChildren = unknown,
+ TSSR = unknown,
+ TMiddlewares = unknown,
+ THandlers = undefined,
+ >(
+ options?: FileBaseRouteOptions<
+ TRegister,
+ TParentRoute,
+ TId,
+ TPath,
+ TSearchValidator,
+ TParams,
+ TLoaderDeps,
+ TLoaderFn,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ AnyContext,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ > &
+ UpdatableRouteOptions<
+ TParentRoute,
+ TId,
+ TFullPath,
+ TParams,
+ TSearchValidator,
+ TLoaderFn,
+ TLoaderDeps,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn
+ >,
+ ): Route<
+ TRegister,
+ TParentRoute,
+ TPath,
+ TFullPath,
+ TFilePath,
+ TId,
+ TSearchValidator,
+ TParams,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ unknown,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ > => {
+ const route = createRoute(options as any)
+ ; (route as any).isRoot = false
+ return route as any
+ }
+}
+
+declare module '@tanstack/router-core' {
+ export interface LazyRoute {
+ injectMatch: InjectMatchRoute
+ injectRouteContext: InjectRouteContextRoute
+ injectSearch: InjectSearchRoute
+ injectParams: InjectParamsRoute
+ injectLoaderDeps: InjectLoaderDepsRoute
+ injectLoaderData: InjectLoaderDataRoute
+ injectNavigate: () => InjectNavigateResult
+ }
+}
+
+export class LazyRoute {
+ options: {
+ id: string
+ } & LazyRouteOptions
+
+ constructor(
+ opts: {
+ id: string
+ } & LazyRouteOptions,
+ ) {
+ this.options = opts
+ }
+
+ injectMatch: InjectMatchRoute = (opts) => {
+ return injectMatch({
+ select: opts?.select,
+ from: this.options.id,
+ } as any) as any
+ }
+
+ injectRouteContext: InjectRouteContextRoute = (opts) => {
+ return injectMatch({
+ from: this.options.id,
+ select: (d: any) => (opts?.select ? opts.select(d.context) : d.context),
+ }) as any
+ }
+
+ injectSearch: InjectSearchRoute = (opts) => {
+ return injectSearch({
+ select: opts?.select,
+ from: this.options.id,
+ } as any) as any
+ }
+
+ injectParams: InjectParamsRoute = (opts) => {
+ return injectParams({
+ select: opts?.select,
+ from: this.options.id,
+ } as any) as any
+ }
+
+ injectLoaderDeps: InjectLoaderDepsRoute = (opts) => {
+ return injectLoaderDeps({ ...opts, from: this.options.id } as any)
+ }
+
+ injectLoaderData: InjectLoaderDataRoute = (opts) => {
+ return injectLoaderData({ ...opts, from: this.options.id } as any)
+ }
+
+ injectNavigate = (): InjectNavigateResult => {
+ const router = injectRouter()
+ return injectNavigate({ from: router.routesById[this.options.id].fullPath })
+ }
+}
+
+/**
+ * Creates a lazily-configurable code-based route stub by ID.
+ *
+ * Use this for code-splitting with code-based routes. The returned function
+ * accepts only non-critical route options like `component`, `pendingComponent`,
+ * `errorComponent`, and `notFoundComponent` which are applied when the route
+ * is matched.
+ *
+ * @param id Route ID string literal to associate with the lazy route.
+ * @returns A function that accepts lazy route options and returns a `LazyRoute`.
+ * @link https://tanstack.com/router/latest/docs/framework/react/api/router/createLazyRouteFunction
+ */
+export function createLazyRoute<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TId extends string = string,
+ TRoute extends AnyRoute = RouteById,
+>(id: ConstrainLiteral>) {
+ return (opts: LazyRouteOptions) => {
+ return new LazyRoute({
+ id: id,
+ ...opts,
+ })
+ }
+}
+
+export function createLazyFileRoute<
+ TFilePath extends keyof FileRoutesByPath,
+ TRoute extends FileRoutesByPath[TFilePath]['preLoaderRoute'],
+>(id: TFilePath): (opts: LazyRouteOptions) => LazyRoute {
+ if (typeof id === 'object') {
+ return new LazyRoute(id) as any
+ }
+
+ return (opts: LazyRouteOptions) => new LazyRoute({ id, ...opts })
+}
diff --git a/packages/angular-router-experimental/src/index.ts b/packages/angular-router-experimental/src/index.ts
new file mode 100644
index 00000000000..c8372ea28bc
--- /dev/null
+++ b/packages/angular-router-experimental/src/index.ts
@@ -0,0 +1,146 @@
+// Router
+export { createRouter, Router } from './router'
+
+// Route creation
+export {
+ createRoute,
+ createRootRoute,
+ createRootRouteWithContext,
+ createRouteMask,
+ getRouteApi,
+ Route,
+ RootRoute,
+ NotFoundRoute,
+ RouteApi,
+ type AnyRootRoute,
+ type RouteComponent,
+ type ErrorRouteComponent,
+ type NotFoundRouteComponent,
+} from './route'
+
+export {
+ createFileRoute,
+ LazyRoute,
+ createLazyRoute,
+ createLazyFileRoute,
+} from './fileRoute'
+
+// Router Provider
+export { RouterProvider } from './RouterProvider'
+
+// Components
+export { Outlet, RouteMatch } from './Match'
+export { Matches } from './Matches'
+
+// Injection functions
+export { injectRouter, type InjectRouterResult } from './injectRouter'
+
+export {
+ injectRouterState,
+ type InjectRouterStateOptions,
+ type InjectRouterStateResult,
+} from './injectRouterState'
+
+export { injectNavigate, type InjectNavigateResult } from './injectNavigate'
+
+export {
+ injectMatch,
+ type InjectMatchOptions,
+ type InjectMatchResult,
+ type InjectMatchRoute,
+ type InjectMatchBaseOptions,
+} from './injectMatch'
+
+export {
+ injectParams,
+ type InjectParamsOptions,
+ type InjectParamsRoute,
+ type InjectParamsBaseOptions,
+} from './injectParams'
+
+export {
+ injectSearch,
+ type InjectSearchOptions,
+ type InjectSearchRoute,
+ type InjectSearchBaseOptions,
+} from './injectSearch'
+
+export {
+ injectLoaderData,
+ type InjectLoaderDataOptions,
+ type InjectLoaderDataRoute,
+ type InjectLoaderDataBaseOptions,
+} from './injectLoaderData'
+
+export {
+ injectLoaderDeps,
+ type InjectLoaderDepsOptions,
+ type InjectLoaderDepsRoute,
+ type InjectLoaderDepsBaseOptions,
+} from './injectLoaderDeps'
+
+export {
+ injectRouterContext,
+ type InjectRouteContextRoute,
+} from './injectRouteContext'
+
+export {
+ injectLocation,
+ type InjectLocationOptions,
+ type InjectLocationResult,
+} from './injectLocationResult'
+
+export {
+ injectBlocker,
+ type InjectBlockerOpts,
+ type UseBlockerOpts,
+ type ShouldBlockFn,
+} from './injectBlocker'
+
+export { injectCanGoBack } from './injectCanGoBack'
+
+export { injectErrorState } from './injectErrorState'
+
+// Link
+export { type LinkOptions as LinkInputOptions, Link } from './Link'
+
+// Core re-exports
+export {
+ notFound,
+ redirect,
+ isRedirect,
+ retainSearchParams,
+ createRouterConfig,
+} from '@tanstack/router-core'
+
+// History utilities
+export {
+ createHistory,
+ createBrowserHistory,
+ createHashHistory,
+ createMemoryHistory,
+} from '@tanstack/history'
+
+export type {
+ BlockerFn,
+ HistoryLocation,
+ RouterHistory,
+ ParsedPath,
+ HistoryState,
+} from '@tanstack/history'
+
+// Re-export types from router-core that are commonly used (FileRoutesByPath augmented by routeTree.gen.ts via declare module '@tanstack/router-core')
+export type {
+ AnyRouter,
+ RegisteredRouter,
+ RouterState,
+ LinkOptions,
+ NavigateOptions,
+ RouteOptions,
+ RootRouteOptions,
+ Register,
+ RouterContextOptions,
+ FileRoutesByPath,
+ CreateFileRoute,
+ CreateLazyFileRoute,
+} from '@tanstack/router-core'
diff --git a/packages/angular-router-experimental/src/injectBlocker.ts b/packages/angular-router-experimental/src/injectBlocker.ts
new file mode 100644
index 00000000000..4c4f1be0b1b
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectBlocker.ts
@@ -0,0 +1,199 @@
+import * as Angular from '@angular/core'
+import { injectRouter } from './injectRouter'
+import type {
+ BlockerFnArgs,
+ HistoryAction,
+ HistoryLocation,
+} from '@tanstack/history'
+import type {
+ AnyRoute,
+ AnyRouter,
+ ParseRoute,
+ RegisteredRouter,
+} from '@tanstack/router-core'
+
+interface ShouldBlockFnLocation<
+ out TRouteId,
+ out TFullPath,
+ out TAllParams,
+ out TFullSearchSchema,
+> {
+ routeId: TRouteId
+ fullPath: TFullPath
+ pathname: string
+ params: TAllParams
+ search: TFullSearchSchema
+}
+
+type AnyShouldBlockFnLocation = ShouldBlockFnLocation
+
+type MakeShouldBlockFnLocationUnion<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TRoute extends AnyRoute = ParseRoute,
+> = TRoute extends any
+ ? ShouldBlockFnLocation<
+ TRoute['id'],
+ TRoute['fullPath'],
+ TRoute['types']['allParams'],
+ TRoute['types']['fullSearchSchema']
+ >
+ : never
+
+type BlockerResolver =
+ | {
+ status: 'blocked'
+ current: MakeShouldBlockFnLocationUnion
+ next: MakeShouldBlockFnLocationUnion
+ action: HistoryAction
+ proceed: () => void
+ reset: () => void
+ }
+ | {
+ status: 'idle'
+ current: undefined
+ next: undefined
+ action: undefined
+ proceed: undefined
+ reset: undefined
+ }
+
+type ShouldBlockFnArgs = {
+ current: MakeShouldBlockFnLocationUnion
+ next: MakeShouldBlockFnLocationUnion
+ action: HistoryAction
+}
+
+export type ShouldBlockFn = (
+ args: ShouldBlockFnArgs,
+) => boolean | Promise
+
+export type UseBlockerOpts<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TWithResolver extends boolean = boolean,
+> = {
+ shouldBlockFn: ShouldBlockFn
+ enableBeforeUnload?: boolean | (() => boolean)
+ disabled?: boolean | (() => boolean)
+ withResolver?: TWithResolver
+}
+
+export type InjectBlockerOpts<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TWithResolver extends boolean = boolean,
+> = {
+ shouldBlockFn: ShouldBlockFn
+ enableBeforeUnload?: boolean | (() => boolean)
+ disabled?: boolean | (() => boolean)
+ withResolver?: TWithResolver
+}
+
+export function injectBlocker<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TWithResolver extends boolean = boolean,
+>(
+ opts: InjectBlockerOpts,
+): TWithResolver extends true
+ ? Angular.Signal>
+ : void {
+ const shouldBlockFn = opts.shouldBlockFn as ShouldBlockFn
+ const router = injectRouter()
+
+ const isDisabled = Angular.computed(() => {
+ return typeof opts.disabled === 'function'
+ ? opts.disabled()
+ : (opts.disabled ?? false)
+ })
+
+ const resolver = Angular.signal({
+ status: 'idle',
+ current: undefined,
+ next: undefined,
+ action: undefined,
+ proceed: undefined,
+ reset: undefined,
+ })
+
+ Angular.effect((onCleanup) => {
+ const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => {
+ function getLocation(
+ location: HistoryLocation,
+ ): AnyShouldBlockFnLocation {
+ const parsedLocation = router.parseLocation(location)
+ const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname)
+ if (matchedRoutes.foundRoute === undefined) {
+ return {
+ routeId: '__notFound__',
+ fullPath: parsedLocation.pathname,
+ pathname: parsedLocation.pathname,
+ params: matchedRoutes.routeParams,
+ search: parsedLocation.search,
+ }
+ }
+ return {
+ routeId: matchedRoutes.foundRoute.id,
+ fullPath: matchedRoutes.foundRoute.fullPath,
+ pathname: parsedLocation.pathname,
+ params: matchedRoutes.routeParams,
+ search: parsedLocation.search,
+ }
+ }
+
+ const current = getLocation(blockerFnArgs.currentLocation)
+ const next = getLocation(blockerFnArgs.nextLocation)
+
+ if (
+ current.routeId === '__notFound__' &&
+ next.routeId !== '__notFound__'
+ ) {
+ return false
+ }
+
+ const shouldBlock = await shouldBlockFn({
+ action: blockerFnArgs.action,
+ current,
+ next,
+ })
+ if (!opts.withResolver) {
+ return shouldBlock
+ }
+
+ if (!shouldBlock) {
+ return false
+ }
+
+ const promise = new Promise((resolve) => {
+ resolver.set({
+ status: 'blocked',
+ current,
+ next,
+ action: blockerFnArgs.action,
+ proceed: () => resolve(false),
+ reset: () => resolve(true),
+ })
+ })
+
+ const canNavigateAsync = await promise
+ resolver.set({
+ status: 'idle',
+ current: undefined,
+ next: undefined,
+ action: undefined,
+ proceed: undefined,
+ reset: undefined,
+ })
+
+ return canNavigateAsync
+ }
+
+ const disposeBlock = isDisabled()
+ ? undefined
+ : router.history.block({
+ blockerFn: blockerFnComposed,
+ enableBeforeUnload: opts.enableBeforeUnload,
+ })
+
+ onCleanup(() => disposeBlock?.())
+ })
+
+ return resolver.asReadonly() as any
+}
diff --git a/packages/angular-router-experimental/src/injectCanGoBack.ts b/packages/angular-router-experimental/src/injectCanGoBack.ts
new file mode 100644
index 00000000000..23560a145d3
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectCanGoBack.ts
@@ -0,0 +1,7 @@
+import { injectRouterState } from './injectRouterState'
+
+export function injectCanGoBack() {
+ return injectRouterState({
+ select: (s) => s.location.state.__TSR_index !== 0,
+ })
+}
diff --git a/packages/angular-router-experimental/src/injectErrorState.ts b/packages/angular-router-experimental/src/injectErrorState.ts
new file mode 100644
index 00000000000..b015f5d7957
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectErrorState.ts
@@ -0,0 +1,21 @@
+import * as Angular from '@angular/core'
+
+export const ERROR_STATE_INJECTOR_TOKEN = new Angular.InjectionToken<{
+ error: Error
+ reset: () => void
+ info: { componentStack: string }
+}>('ERROR_STATE_INJECTOR_TOKEN')
+
+/**
+ * Injects the error state to the error componenet.
+ */
+
+export function injectErrorState() {
+ const errorState = Angular.inject(ERROR_STATE_INJECTOR_TOKEN, {
+ optional: true,
+ })
+ if (!errorState) {
+ throw new Error('injectErrorState was called outside of an error component')
+ }
+ return errorState
+}
diff --git a/packages/angular-router-experimental/src/injectIntersectionObserver.ts b/packages/angular-router-experimental/src/injectIntersectionObserver.ts
new file mode 100644
index 00000000000..0fe01983214
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectIntersectionObserver.ts
@@ -0,0 +1,28 @@
+import * as Angular from '@angular/core'
+
+export function injectIntersectionObserver(
+ callback: (entry: IntersectionObserverEntry | undefined) => void,
+ intersectionObserverOptions: IntersectionObserverInit,
+ disabled: () => boolean,
+) {
+ const elementRef = Angular.inject(Angular.ElementRef)
+
+ Angular.afterRenderEffect((onCleanup) => {
+ const isIntersectionObserverAvailable =
+ typeof IntersectionObserver === 'function'
+
+ const element = elementRef.nativeElement as HTMLElement | null
+ if (!element || !isIntersectionObserverAvailable || disabled()) return
+
+ const observer = new IntersectionObserver(
+ ([entry]) => callback(entry),
+ intersectionObserverOptions,
+ )
+
+ observer.observe(element)
+
+ onCleanup(() => {
+ observer.disconnect()
+ })
+ })
+}
diff --git a/packages/angular-router-experimental/src/injectLoaderData.ts b/packages/angular-router-experimental/src/injectLoaderData.ts
new file mode 100644
index 00000000000..6ca327a83af
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectLoaderData.ts
@@ -0,0 +1,49 @@
+import { injectMatch } from './injectMatch'
+import type * as Angular from '@angular/core'
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ ResolveUseLoaderData,
+ StrictOrFrom,
+ UseLoaderDataResult,
+} from '@tanstack/router-core'
+
+export interface InjectLoaderDataBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TSelected,
+> {
+ select?: (match: ResolveUseLoaderData) => TSelected
+}
+
+export type InjectLoaderDataOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TStrict extends boolean,
+ TSelected,
+> = StrictOrFrom &
+ InjectLoaderDataBaseOptions
+
+export type InjectLoaderDataRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectLoaderDataBaseOptions,
+) => Angular.Signal>
+
+export function injectLoaderData<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TSelected = unknown,
+>(
+ opts: InjectLoaderDataOptions,
+): Angular.Signal> {
+ return injectMatch({
+ from: opts.from!,
+ strict: opts.strict as true | undefined,
+ select: (s: any) =>
+ opts.select ? opts.select(s.loaderData) : s.loaderData,
+ } as any) as any
+}
diff --git a/packages/angular-router-experimental/src/injectLoaderDeps.ts b/packages/angular-router-experimental/src/injectLoaderDeps.ts
new file mode 100644
index 00000000000..7185fc4408d
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectLoaderDeps.ts
@@ -0,0 +1,45 @@
+import { injectMatch } from './injectMatch'
+import type * as Angular from '@angular/core'
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ ResolveUseLoaderDeps,
+ StrictOrFrom,
+ UseLoaderDepsResult,
+} from '@tanstack/router-core'
+
+export interface InjectLoaderDepsBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TSelected,
+> {
+ select?: (deps: ResolveUseLoaderDeps) => TSelected
+}
+
+export type InjectLoaderDepsOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TSelected,
+> = StrictOrFrom &
+ InjectLoaderDepsBaseOptions
+
+export type InjectLoaderDepsRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectLoaderDepsBaseOptions,
+) => Angular.Signal>
+
+export function injectLoaderDeps<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TSelected = unknown,
+>(
+ opts: InjectLoaderDepsOptions,
+): Angular.Signal> {
+ const { select, ...rest } = opts
+ return injectMatch({
+ ...rest,
+ select: (s) => (select ? select(s.loaderDeps) : s.loaderDeps),
+ }) as any
+}
diff --git a/packages/angular-router-experimental/src/injectLocationResult.ts b/packages/angular-router-experimental/src/injectLocationResult.ts
new file mode 100644
index 00000000000..e5bc1d09cea
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectLocationResult.ts
@@ -0,0 +1,27 @@
+import { injectRouterState } from './injectRouterState'
+import type * as Angular from '@angular/core'
+import type { AnyRouter, RegisteredRouter, RouterState } from '@tanstack/router-core'
+
+export interface InjectLocationOptions {
+ select?: (
+ location: RouterState['location'],
+ ) => TSelected
+}
+
+export type InjectLocationResult<
+ TRouter extends AnyRouter,
+ TSelected,
+> = unknown extends TSelected
+ ? RouterState['location']
+ : TSelected
+
+export function injectLocation<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectLocationOptions,
+): Angular.Signal> {
+ return injectRouterState({
+ select: (s) => (opts?.select ? opts.select(s.location) : s.location),
+ }) as any
+}
diff --git a/packages/angular-router-experimental/src/injectMatch.ts b/packages/angular-router-experimental/src/injectMatch.ts
new file mode 100644
index 00000000000..3f675524d61
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectMatch.ts
@@ -0,0 +1,120 @@
+import * as Angular from '@angular/core'
+import invariant from 'tiny-invariant'
+import { injectRouterState } from './injectRouterState'
+import {
+ DUMMY_MATCH_ID_INJECTOR_TOKEN,
+ MATCH_ID_INJECTOR_TOKEN,
+} from './matchInjectorToken'
+import type {
+ AnyRouter,
+ MakeRouteMatch,
+ MakeRouteMatchUnion,
+ RegisteredRouter,
+ StrictOrFrom,
+ ThrowConstraint,
+ ThrowOrOptional,
+} from '@tanstack/router-core'
+
+export interface InjectMatchBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+> {
+ select?: (
+ match: MakeRouteMatch,
+ ) => TSelected
+ shouldThrow?: TThrow
+}
+
+export type InjectMatchRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectMatchBaseOptions,
+) => Angular.Signal>
+
+export type InjectMatchOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+> = StrictOrFrom &
+ InjectMatchBaseOptions
+
+export type InjectMatchResult<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TSelected,
+> = unknown extends TSelected
+ ? TStrict extends true
+ ? MakeRouteMatch
+ : MakeRouteMatchUnion
+ : TSelected
+
+export function injectMatch<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TThrow extends boolean = true,
+ TSelected = unknown,
+>(
+ opts: InjectMatchOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ ThrowConstraint,
+ TSelected
+ >,
+): Angular.Signal<
+ ThrowOrOptional, TThrow>
+> {
+ const nearestMatchId = Angular.inject(
+ opts.from ? DUMMY_MATCH_ID_INJECTOR_TOKEN : MATCH_ID_INJECTOR_TOKEN,
+ )
+
+ const matchState = injectRouterState({
+ select: (state) => {
+ const match = state.matches.find((d) =>
+ opts.from ? opts.from === d.routeId : d.id === nearestMatchId(),
+ )
+
+ if (match === undefined) {
+ // During navigation transitions, check if the match exists in pendingMatches
+ const pendingMatch = state.pendingMatches?.find((d) =>
+ opts.from ? opts.from === d.routeId : d.id === nearestMatchId(),
+ )
+
+ // Determine if we should throw an error
+ const shouldThrowError =
+ !pendingMatch && !state.isTransitioning && (opts.shouldThrow ?? true)
+
+ return {
+ match: undefined,
+ shouldThrowError,
+ } as const
+ }
+
+ return {
+ match: opts.select ? opts.select(match) : match,
+ shouldThrowError: false,
+ } as const
+ },
+ })
+
+ // Throw the error if we have one - this happens after the selector runs
+ // Using a computed so the error is thrown when the return value is accessed
+ return Angular.computed(() => {
+ const state = matchState()
+ if (state.shouldThrowError) {
+ invariant(
+ false,
+ `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`,
+ )
+ }
+ return state.match as any
+ })
+}
diff --git a/packages/angular-router-experimental/src/injectNavigate.ts b/packages/angular-router-experimental/src/injectNavigate.ts
new file mode 100644
index 00000000000..99467c8e73f
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectNavigate.ts
@@ -0,0 +1,30 @@
+import { injectRouter } from './injectRouter'
+import type {
+ AnyRouter,
+ FromPathOption,
+ NavigateOptions,
+ RegisteredRouter,
+ UseNavigateResult,
+} from '@tanstack/router-core'
+
+export function injectNavigate<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDefaultFrom extends string = string,
+>(_defaultOpts?: {
+ from?: FromPathOption
+}): UseNavigateResult {
+ const router = injectRouter()
+
+ return ((options: NavigateOptions) => {
+ return router.navigate({
+ ...options,
+ from: options.from ?? _defaultOpts?.from,
+ })
+ }) as UseNavigateResult
+}
+
+export type InjectNavigateResult<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDefaultFrom extends string = string,
+> = UseNavigateResult &
+ (TRouter extends AnyRouter ? unknown : never)
diff --git a/packages/angular-router-experimental/src/injectParams.ts b/packages/angular-router-experimental/src/injectParams.ts
new file mode 100644
index 00000000000..a1d2a1c1e4a
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectParams.ts
@@ -0,0 +1,73 @@
+import { injectMatch } from './injectMatch'
+import type * as Angular from '@angular/core'
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ ResolveUseParams,
+ StrictOrFrom,
+ ThrowConstraint,
+ ThrowOrOptional,
+ UseParamsResult,
+} from '@tanstack/router-core'
+
+export interface InjectParamsBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+> {
+ select?: (params: ResolveUseParams) => TSelected
+ shouldThrow?: TThrow
+}
+
+export type InjectParamsOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+> = StrictOrFrom &
+ InjectParamsBaseOptions
+
+export type InjectParamsRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectParamsBaseOptions<
+ TRouter,
+ TFrom,
+ /* TStrict */ true,
+ /* TThrow */ true,
+ TSelected
+ >,
+) => Angular.Signal>
+
+export function injectParams<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TThrow extends boolean = true,
+ TSelected = unknown,
+>(
+ opts: InjectParamsOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ ThrowConstraint,
+ TSelected
+ >,
+): Angular.Signal<
+ ThrowOrOptional, TThrow>
+> {
+ return injectMatch({
+ from: opts.from!,
+ strict: opts.strict as true | undefined,
+ shouldThrow: opts.shouldThrow,
+ select: (match: any) => {
+ const params = opts.strict === false ? match.params : match._strictParams
+
+ return opts.select ? opts.select(params) : params
+ },
+ } as any) as Angular.Signal
+}
diff --git a/packages/angular-router-experimental/src/injectRouteContext.ts b/packages/angular-router-experimental/src/injectRouteContext.ts
new file mode 100644
index 00000000000..f458c60cc38
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectRouteContext.ts
@@ -0,0 +1,31 @@
+import { injectMatch } from './injectMatch'
+import type * as Angular from '@angular/core'
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ UseRouteContextBaseOptions,
+ UseRouteContextOptions,
+ UseRouteContextResult,
+} from '@tanstack/router-core'
+
+export type InjectRouteContextRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: UseRouteContextBaseOptions,
+) => Angular.Signal>
+
+export function injectRouterContext<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TSelected = unknown,
+>(
+ opts: UseRouteContextOptions,
+): Angular.Signal> {
+ return injectMatch({
+ ...opts,
+ select: (match) =>
+ opts.select ? opts.select(match.context) : match.context,
+ }) as any
+}
diff --git a/packages/angular-router-experimental/src/injectRouter.ts b/packages/angular-router-experimental/src/injectRouter.ts
new file mode 100644
index 00000000000..a9d0fd1fcb7
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectRouter.ts
@@ -0,0 +1,18 @@
+import * as Angular from '@angular/core'
+import warning from 'tiny-warning'
+import { getRouterInjectionKey } from './routerInjectionToken'
+import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core'
+
+export function injectRouter<
+ TRouter extends AnyRouter = RegisteredRouter,
+>(opts?: { warn?: boolean }): TRouter {
+ const router = Angular.inject(getRouterInjectionKey(), { optional: true })
+ warning(
+ !((opts?.warn ?? true) && !router),
+ 'injectRouter must be used inside a component!',
+ )
+ return router as any
+}
+
+export type InjectRouterResult =
+ TRouter
diff --git a/packages/angular-router-experimental/src/injectRouterState.ts b/packages/angular-router-experimental/src/injectRouterState.ts
new file mode 100644
index 00000000000..af425f607c4
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectRouterState.ts
@@ -0,0 +1,37 @@
+import { injectStore } from '@tanstack/angular-store'
+import { injectRouter } from './injectRouter'
+import type * as Angular from '@angular/core'
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ RouterState,
+} from '@tanstack/router-core'
+
+export type InjectRouterStateOptions = {
+ router?: TRouter
+ select?: (state: RouterState) => TSelected
+}
+
+export type InjectRouterStateResult<
+ TRouter extends AnyRouter,
+ TSelected,
+> = unknown extends TSelected ? RouterState : TSelected
+
+export function injectRouterState<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectRouterStateOptions,
+): Angular.Signal> {
+ const contextRouter = injectRouter({
+ warn: opts?.router === undefined,
+ })
+
+ const router = opts?.router ?? contextRouter
+
+ return injectStore(router.__store, (state) => {
+ if (opts?.select) return opts.select(state)
+
+ return state
+ }) as Angular.Signal>
+}
diff --git a/packages/angular-router-experimental/src/injectSearch.ts b/packages/angular-router-experimental/src/injectSearch.ts
new file mode 100644
index 00000000000..608a3656252
--- /dev/null
+++ b/packages/angular-router-experimental/src/injectSearch.ts
@@ -0,0 +1,71 @@
+import { injectMatch } from './injectMatch'
+import type * as Angular from '@angular/core'
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ ResolveUseSearch,
+ StrictOrFrom,
+ ThrowConstraint,
+ ThrowOrOptional,
+ UseSearchResult,
+} from '@tanstack/router-core'
+
+export interface InjectSearchBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+> {
+ select?: (state: ResolveUseSearch) => TSelected
+ shouldThrow?: TThrow
+}
+
+export type InjectSearchOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+> = StrictOrFrom &
+ InjectSearchBaseOptions
+
+export type InjectSearchRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: InjectSearchBaseOptions<
+ TRouter,
+ TFrom,
+ /* TStrict */ true,
+ /* TThrow */ true,
+ TSelected
+ >,
+) => Angular.Signal>
+
+export function injectSearch<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TThrow extends boolean = true,
+ TSelected = unknown,
+>(
+ opts: InjectSearchOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ ThrowConstraint,
+ TSelected
+ >,
+): Angular.Signal<
+ ThrowOrOptional, TThrow>
+> {
+ return injectMatch({
+ from: opts.from!,
+ strict: opts.strict,
+ shouldThrow: opts.shouldThrow,
+ select: (match: any) => {
+ return opts.select ? opts.select(match.search) : match.search
+ },
+ } as any) as any
+}
diff --git a/packages/angular-router-experimental/src/matchInjectorToken.ts b/packages/angular-router-experimental/src/matchInjectorToken.ts
new file mode 100644
index 00000000000..e3bcadbc4e8
--- /dev/null
+++ b/packages/angular-router-experimental/src/matchInjectorToken.ts
@@ -0,0 +1,14 @@
+import * as Angular from '@angular/core'
+
+export const MATCH_ID_INJECTOR_TOKEN = new Angular.InjectionToken<
+ Angular.Signal
+>('MATCH_ID_INJECTOR', {
+ factory: () => Angular.signal(undefined),
+})
+
+// N.B. this only exists so we can conditionally inject a value when we are not interested in the nearest match
+export const DUMMY_MATCH_ID_INJECTOR_TOKEN = new Angular.InjectionToken<
+ Angular.Signal
+>('DUMMY_MATCH_ID_INJECTOR', {
+ factory: () => Angular.signal(undefined),
+})
diff --git a/packages/angular-router-experimental/src/renderer/injectIsCatchingError.ts b/packages/angular-router-experimental/src/renderer/injectIsCatchingError.ts
new file mode 100644
index 00000000000..d5a3b41a1a6
--- /dev/null
+++ b/packages/angular-router-experimental/src/renderer/injectIsCatchingError.ts
@@ -0,0 +1,40 @@
+import * as Angular from '@angular/core'
+import { injectRouter } from '../injectRouter'
+import { injectRouterState } from '../injectRouterState'
+import type { AnyRoute } from '@tanstack/router-core'
+
+export function injectIsCatchingError({
+ matchId,
+}: {
+ matchId: Angular.Signal
+}): Angular.Signal {
+ const router = injectRouter()
+
+ const matches = injectRouterState({
+ select: (s) => s.matches,
+ })
+
+ const matchIndex = Angular.computed(() => {
+ return matches().findIndex((m) => m.id === matchId())
+ })
+
+ return Angular.computed(() => {
+ // The child route will handle the error with the default error component.
+ if (router.options.defaultErrorComponent != null) return false;
+
+ const startingIndex = matchIndex()
+ if (startingIndex === -1) return false
+ const matchesList = matches()
+
+ for (let i = startingIndex + 1; i < matchesList.length; i++) {
+ const descendant = matchesList[i]
+ const route = router.routesById[descendant?.routeId] as AnyRoute
+ // Is catched by a child route with an error component.
+ if (route.options.errorComponent != null) return false
+
+ // Found error status without error component in between.
+ if (descendant?.status === "error") return true
+ }
+ return false
+ })
+}
diff --git a/packages/angular-router-experimental/src/renderer/injectRender.ts b/packages/angular-router-experimental/src/renderer/injectRender.ts
new file mode 100644
index 00000000000..052d83a0ca1
--- /dev/null
+++ b/packages/angular-router-experimental/src/renderer/injectRender.ts
@@ -0,0 +1,59 @@
+import * as Angular from '@angular/core'
+
+export type RenderValue = {
+ key?: string
+ component: Angular.Type | null | undefined
+ inputs?: Record unknown>
+ providers?: Array
+} | null | undefined
+
+export function injectRender(renderValueFn: () => RenderValue): void {
+ const vcr = Angular.inject(Angular.ViewContainerRef)
+ const parent = Angular.inject(Angular.Injector)
+
+ Angular.inject(Angular.DestroyRef).onDestroy(() => {
+ vcr.clear()
+ })
+
+ let lastKey: Array = [];
+
+ Angular.effect(() => {
+ const renderValue = renderValueFn()
+
+ const newKey = resolvedKey(renderValue)
+ if (keysAreEqual(lastKey, newKey)) return
+
+ // Clear if there was a previous value
+ if (lastKey.length > 0) vcr.clear()
+
+ // Update component
+ lastKey = newKey
+
+ // No value, do not render
+ const component = renderValue?.component
+ if (!component) return
+
+ // Angular renderer code
+ const providers = renderValue.providers ?? []
+ const injector = Angular.Injector.create({ providers, parent })
+ const bindings = Object.entries(renderValue.inputs ?? {}).map(([name, value]) =>
+ Angular.inputBinding(name, value),
+ )
+ const cmpRef = vcr.createComponent(component, { injector, bindings })
+ cmpRef.changeDetectorRef.markForCheck()
+ })
+}
+
+function resolvedKey(value: RenderValue) {
+ const component = value?.component
+ if (!value || !component) return []
+ return [component, value.key]
+}
+
+function keysAreEqual(a: Array, b: Array) {
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false
+ }
+ return true
+}
diff --git a/packages/angular-router-experimental/src/route.ts b/packages/angular-router-experimental/src/route.ts
new file mode 100644
index 00000000000..3c97bf9713c
--- /dev/null
+++ b/packages/angular-router-experimental/src/route.ts
@@ -0,0 +1,643 @@
+import {
+ BaseRootRoute,
+ BaseRoute,
+ BaseRouteApi,
+ notFound,
+} from '@tanstack/router-core'
+import { injectLoaderData } from './injectLoaderData'
+import { injectLoaderDeps } from './injectLoaderDeps'
+import { injectMatch } from './injectMatch'
+import { injectNavigate } from './injectNavigate'
+import { injectParams } from './injectParams'
+import { injectRouter } from './injectRouter'
+import { injectSearch } from './injectSearch'
+import type { InjectParamsRoute } from './injectParams'
+import type { InjectRouteContextRoute } from './injectRouteContext'
+import type { InjectMatchRoute } from './injectMatch'
+import type { InjectLoaderDepsRoute } from './injectLoaderDeps'
+import type { InjectLoaderDataRoute } from './injectLoaderData'
+import type * as Angular from '@angular/core'
+import type {
+ AnyContext,
+ AnyRoute,
+ AnyRouter,
+ ConstrainLiteral,
+ NotFoundError,
+ Register,
+ RegisteredRouter,
+ ResolveFullPath,
+ ResolveId,
+ ResolveParams,
+ RootRoute as RootRouteCore,
+ RootRouteId,
+ RootRouteOptions,
+ RouteConstraints,
+ Route as RouteCore,
+ RouteIds,
+ RouteMask,
+ RouteOptions,
+ RouteTypesById,
+ RouterCore,
+ ToMaskOptions,
+ UseNavigateResult,
+} from '@tanstack/router-core'
+import type { InjectSearchRoute } from './injectSearch'
+
+declare module '@tanstack/router-core' {
+ export interface UpdatableRouteOptionsExtensions {
+ component?: RouteComponent
+ errorComponent?: false | null | undefined | ErrorRouteComponent
+ notFoundComponent?: NotFoundRouteComponent
+ pendingComponent?: RouteComponent
+ }
+
+ export interface RootRouteOptionsExtensions {
+ shellComponent?: Angular.Type<{
+ children: any
+ }>
+ }
+
+ export interface RouteExtensions<
+ in out TId extends string,
+ in out TFullPath extends string,
+ > {
+ injectMatch: InjectMatchRoute
+ injectRouteContext: InjectRouteContextRoute
+ injectSearch: InjectSearchRoute
+ injectParams: InjectParamsRoute
+ injectLoaderDeps: InjectLoaderDepsRoute
+ injectLoaderData: InjectLoaderDataRoute
+ injectNavigate: () => UseNavigateResult
+ }
+}
+
+export function getRouteApi<
+ const TId,
+ TRouter extends AnyRouter = RegisteredRouter,
+>(id: ConstrainLiteral>) {
+ return new RouteApi({ id })
+}
+
+export class RouteApi<
+ TId,
+ TRouter extends AnyRouter = RegisteredRouter,
+> extends BaseRouteApi {
+ /**
+ * @deprecated Use the `getRouteApi` function instead.
+ */
+ constructor({ id }: { id: TId }) {
+ super({ id })
+ }
+
+ injectMatch: InjectMatchRoute = (opts) => {
+ return injectMatch({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectRouteContext: InjectRouteContextRoute = (opts) => {
+ return injectMatch({
+ from: this.id as any,
+ select: (d) => (opts?.select ? opts.select(d.context) : d.context),
+ }) as any
+ }
+
+ injectSearch: InjectSearchRoute = (opts) => {
+ return injectSearch({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectParams: InjectParamsRoute = (opts) => {
+ return injectParams({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectLoaderDeps: InjectLoaderDepsRoute = (opts) => {
+ return injectLoaderDeps({ ...opts, from: this.id, strict: false } as any)
+ }
+
+ injectLoaderData: InjectLoaderDataRoute = (opts) => {
+ return injectLoaderData({ ...opts, from: this.id, strict: false } as any)
+ }
+
+ injectNavigate = (): UseNavigateResult<
+ RouteTypesById['fullPath']
+ > => {
+ const router = injectRouter()
+ return injectNavigate({
+ from: router.routesById[this.id as string].fullPath,
+ })
+ }
+
+ notFound = (opts?: NotFoundError) => {
+ return notFound({ routeId: this.id as string, ...opts })
+ }
+}
+
+export class Route<
+ in out TRegister = unknown,
+ in out TParentRoute extends RouteConstraints['TParentRoute'] = AnyRoute,
+ in out TPath extends RouteConstraints['TPath'] = '/',
+ in out TFullPath extends RouteConstraints['TFullPath'] = ResolveFullPath<
+ TParentRoute,
+ TPath
+ >,
+ in out TCustomId extends RouteConstraints['TCustomId'] = string,
+ in out TId extends RouteConstraints['TId'] = ResolveId<
+ TParentRoute,
+ TCustomId,
+ TPath
+ >,
+ in out TSearchValidator = undefined,
+ in out TParams = ResolveParams,
+ in out TRouterContext = AnyContext,
+ in out TRouteContextFn = AnyContext,
+ in out TBeforeLoadFn = AnyContext,
+ in out TLoaderDeps extends Record = {},
+ in out TLoaderFn = undefined,
+ in out TChildren = unknown,
+ in out TFileRouteTypes = unknown,
+ in out TSSR = unknown,
+ in out TMiddlewares = unknown,
+ in out THandlers = undefined,
+ >
+ extends BaseRoute<
+ TRegister,
+ TParentRoute,
+ TPath,
+ TFullPath,
+ TCustomId,
+ TId,
+ TSearchValidator,
+ TParams,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ TFileRouteTypes,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ >
+ implements
+ RouteCore<
+ TRegister,
+ TParentRoute,
+ TPath,
+ TFullPath,
+ TCustomId,
+ TId,
+ TSearchValidator,
+ TParams,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ TFileRouteTypes,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ >
+{
+ /**
+ * @deprecated Use the `createRoute` function instead.
+ */
+ constructor(
+ options?: RouteOptions<
+ TRegister,
+ TParentRoute,
+ TId,
+ TCustomId,
+ TFullPath,
+ TPath,
+ TSearchValidator,
+ TParams,
+ TLoaderDeps,
+ TLoaderFn,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ >,
+ ) {
+ super(options)
+ }
+
+ injectMatch: InjectMatchRoute = (opts?: any) => {
+ return injectMatch({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectRouteContext: InjectRouteContextRoute = (opts?: any) => {
+ return injectMatch({
+ ...opts,
+ from: this.id,
+ select: (d) => (opts?.select ? opts.select(d.context) : d.context),
+ }) as any
+ }
+
+ injectSearch: InjectSearchRoute = (opts) => {
+ return injectSearch({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectParams: InjectParamsRoute = (opts) => {
+ return injectParams({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectLoaderDeps: InjectLoaderDepsRoute = (opts) => {
+ return injectLoaderDeps({ ...opts, from: this.id } as any)
+ }
+
+ injectLoaderData: InjectLoaderDataRoute = (opts) => {
+ return injectLoaderData({ ...opts, from: this.id } as any)
+ }
+
+ injectNavigate = (): UseNavigateResult => {
+ return injectNavigate({ from: this.fullPath })
+ }
+}
+
+export function createRoute<
+ TRegister = unknown,
+ TParentRoute extends RouteConstraints['TParentRoute'] = AnyRoute,
+ TPath extends RouteConstraints['TPath'] = '/',
+ TFullPath extends RouteConstraints['TFullPath'] = ResolveFullPath<
+ TParentRoute,
+ TPath
+ >,
+ TCustomId extends RouteConstraints['TCustomId'] = string,
+ TId extends RouteConstraints['TId'] = ResolveId<
+ TParentRoute,
+ TCustomId,
+ TPath
+ >,
+ TSearchValidator = undefined,
+ TParams = ResolveParams,
+ TRouteContextFn = AnyContext,
+ TBeforeLoadFn = AnyContext,
+ TLoaderDeps extends Record = {},
+ TLoaderFn = undefined,
+ TChildren = unknown,
+ TSSR = unknown,
+ THandlers = undefined,
+>(
+ options: RouteOptions<
+ TRegister,
+ TParentRoute,
+ TId,
+ TCustomId,
+ TFullPath,
+ TPath,
+ TSearchValidator,
+ TParams,
+ TLoaderDeps,
+ TLoaderFn,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TSSR,
+ THandlers
+ >,
+): Route<
+ TRegister,
+ TParentRoute,
+ TPath,
+ TFullPath,
+ TCustomId,
+ TId,
+ TSearchValidator,
+ TParams,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ unknown,
+ TSSR,
+ THandlers
+> {
+ return new Route<
+ TRegister,
+ TParentRoute,
+ TPath,
+ TFullPath,
+ TCustomId,
+ TId,
+ TSearchValidator,
+ TParams,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ unknown,
+ TSSR,
+ THandlers
+ >(options)
+}
+
+export type AnyRootRoute = RootRoute<
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any
+>
+
+export function createRootRouteWithContext() {
+ return <
+ TRegister = Register,
+ TRouteContextFn = AnyContext,
+ TBeforeLoadFn = AnyContext,
+ TSearchValidator = undefined,
+ TLoaderDeps extends Record = {},
+ TLoaderFn = undefined,
+ TSSR = unknown,
+ THandlers = undefined,
+ >(
+ options?: RootRouteOptions<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TSSR,
+ THandlers
+ >,
+ ) => {
+ return createRootRoute<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TSSR,
+ THandlers
+ >(options as any)
+ }
+}
+
+export class RootRoute<
+ in out TRegister = Register,
+ in out TSearchValidator = undefined,
+ in out TRouterContext = {},
+ in out TRouteContextFn = AnyContext,
+ in out TBeforeLoadFn = AnyContext,
+ in out TLoaderDeps extends Record = {},
+ in out TLoaderFn = undefined,
+ in out TChildren = unknown,
+ in out TFileRouteTypes = unknown,
+ in out TSSR = unknown,
+ in out THandlers = undefined,
+ >
+ extends BaseRootRoute<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ TFileRouteTypes,
+ TSSR,
+ THandlers
+ >
+ implements
+ RootRouteCore<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ TFileRouteTypes,
+ TSSR,
+ THandlers
+ >
+{
+ /**
+ * @deprecated `RootRoute` is now an internal implementation detail. Use `createRootRoute()` instead.
+ */
+ constructor(
+ options?: RootRouteOptions<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TSSR,
+ THandlers
+ >,
+ ) {
+ super(options)
+ }
+
+ injectMatch: InjectMatchRoute = (opts?: any) => {
+ return injectMatch({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectRouteContext: InjectRouteContextRoute = (opts) => {
+ return injectMatch({
+ ...opts,
+ from: this.id,
+ select: (d) => (opts?.select ? opts.select(d.context) : d.context),
+ }) as any
+ }
+
+ injectSearch: InjectSearchRoute = (opts) => {
+ return injectSearch({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectParams: InjectParamsRoute = (opts) => {
+ return injectParams({
+ select: opts?.select,
+ from: this.id,
+ } as any) as any
+ }
+
+ injectLoaderDeps: InjectLoaderDepsRoute = (opts) => {
+ return injectLoaderDeps({ ...opts, from: this.id } as any)
+ }
+
+ injectLoaderData: InjectLoaderDataRoute = (opts) => {
+ return injectLoaderData({ ...opts, from: this.id } as any)
+ }
+
+ injectNavigate = (): UseNavigateResult<'/'> => {
+ return injectNavigate({ from: this.fullPath })
+ }
+}
+
+export function createRouteMask<
+ TRouteTree extends AnyRoute,
+ TFrom extends string,
+ TTo extends string,
+>(
+ opts: {
+ routeTree: TRouteTree
+ } & ToMaskOptions, TFrom, TTo>,
+): RouteMask {
+ return opts as any
+}
+
+// Use a function becasue class definitions are not hoisted
+
+export type RouteComponent =
+ () => Angular.Type
+export type ErrorRouteComponent = () => Angular.Type
+export type NotFoundRouteComponent = () => Angular.Type
+
+export class NotFoundRoute<
+ TRegister,
+ TParentRoute extends AnyRootRoute,
+ TRouterContext = AnyContext,
+ TRouteContextFn = AnyContext,
+ TBeforeLoadFn = AnyContext,
+ TSearchValidator = undefined,
+ TLoaderDeps extends Record = {},
+ TLoaderFn = undefined,
+ TChildren = unknown,
+ TSSR = unknown,
+ THandlers = undefined,
+> extends Route<
+ TRegister,
+ TParentRoute,
+ '/404',
+ '/404',
+ '404',
+ '404',
+ TSearchValidator,
+ {},
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ TSSR,
+ THandlers
+> {
+ constructor(
+ options: Omit<
+ RouteOptions<
+ TRegister,
+ TParentRoute,
+ string,
+ string,
+ string,
+ string,
+ TSearchValidator,
+ {},
+ TLoaderDeps,
+ TLoaderFn,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TSSR,
+ THandlers
+ >,
+ | 'caseSensitive'
+ | 'parseParams'
+ | 'stringifyParams'
+ | 'path'
+ | 'id'
+ | 'params'
+ >,
+ ) {
+ super({
+ ...(options as any),
+ id: '404',
+ })
+ }
+}
+
+export function createRootRoute<
+ TRegister = Register,
+ TSearchValidator = undefined,
+ TRouterContext = {},
+ TRouteContextFn = AnyContext,
+ TBeforeLoadFn = AnyContext,
+ TLoaderDeps extends Record = {},
+ TLoaderFn = undefined,
+ TSSR = unknown,
+ THandlers = undefined,
+>(
+ options?: RootRouteOptions<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TSSR,
+ THandlers
+ >,
+): RootRoute<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ unknown,
+ unknown,
+ TSSR,
+ THandlers
+> {
+ return new RootRoute<
+ TRegister,
+ TSearchValidator,
+ TRouterContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ unknown,
+ unknown,
+ TSSR,
+ THandlers
+ >(options)
+}
diff --git a/packages/angular-router-experimental/src/router.ts b/packages/angular-router-experimental/src/router.ts
new file mode 100644
index 00000000000..87ed302bcfc
--- /dev/null
+++ b/packages/angular-router-experimental/src/router.ts
@@ -0,0 +1,75 @@
+import {
+ RouterCore
+} from '@tanstack/router-core'
+import type { RouterHistory } from '@tanstack/history'
+import type { ErrorRouteComponent, RouteComponent } from './route'
+import type {
+ AnyRoute,
+ CreateRouterFn,
+ RouterConstructorOptions,
+ TrailingSlashOption} from '@tanstack/router-core';
+
+declare module '@tanstack/router-core' {
+ export interface RouterOptionsExtensions {
+ /**
+ * The default `component` a route should use if no component is provided.
+ *
+ * @default Outlet
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaultcomponent-property)
+ */
+ defaultComponent?: RouteComponent
+ /**
+ * The default `errorComponent` a route should use if no error component is provided.
+ *
+ * @default ErrorComponent
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaulterrorcomponent-property)
+ * @link [Guide](https://tanstack.com/router/latest/docs/framework/solid/guide/data-loading#handling-errors-with-routeoptionserrorcomponent)
+ */
+ defaultErrorComponent?: ErrorRouteComponent
+ /**
+ * The default `pendingComponent` a route should use if no pending component is provided.
+ *
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaultpendingcomponent-property)
+ * @link [Guide](https://tanstack.com/router/latest/docs/framework/solid/guide/data-loading#showing-a-pending-component)
+ */
+ defaultPendingComponent?: RouteComponent
+ /**
+ * The default `notFoundComponent` a route should use if no notFound component is provided.
+ *
+ * @default NotFound
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaultnotfoundcomponent-property)
+ * @link [Guide](https://tanstack.com/router/latest/docs/framework/solid/guide/not-found-errors#default-router-wide-not-found-handling)
+ */
+ defaultNotFoundComponent?: RouteComponent
+ }
+}
+
+export const createRouter: CreateRouterFn = (options: any) => {
+ return new Router(options)
+}
+
+export class Router<
+ in out TRouteTree extends AnyRoute,
+ in out TTrailingSlashOption extends TrailingSlashOption = 'never',
+ in out TDefaultStructuralSharingOption extends boolean = false,
+ in out TRouterHistory extends RouterHistory = RouterHistory,
+ in out TDehydrated extends Record = Record