Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d525d52
chore(vue): Introduce routing props helper
wobsoriano Nov 19, 2025
0e578d8
chore(nuxt): Default to path routing for UI components
wobsoriano Nov 19, 2025
b641c7c
fix org and user profile routing
wobsoriano Nov 19, 2025
426c6a4
chore: remove unused type in tsup config
wobsoriano Nov 19, 2025
76e4deb
chore: add vue to workspace
wobsoriano Nov 19, 2025
c862cf7
test: update integration tests
wobsoriano Nov 19, 2025
e062f55
Merge branch 'vincent-and-the-doctor' into rob/nuxt-routing-path
wobsoriano Nov 19, 2025
a69c7c3
chore: add changeset
wobsoriano Nov 19, 2025
a99f257
chore: use wrapped ui components
wobsoriano Nov 20, 2025
4776e50
inherit attrs
wobsoriano Nov 20, 2025
12de042
Merge branch 'vincent-and-the-doctor' into rob/nuxt-routing-path
wobsoriano Nov 20, 2025
6da2b86
chore: update changeset
wobsoriano Nov 21, 2025
7ae9988
chore: add changeset
wobsoriano Nov 21, 2025
dae3cb4
Merge branch 'vincent-and-the-doctor' into rob/nuxt-routing-path
wobsoriano Nov 21, 2025
ca6612e
chore: update changeset
wobsoriano Nov 21, 2025
389e4c5
Merge branch 'vincent-and-the-doctor' into rob/nuxt-routing-path
wobsoriano Nov 21, 2025
1cb0481
chore: add comment regarding attrs usage
wobsoriano Nov 21, 2025
c09d315
chore: add comment regarding attrs usage
wobsoriano Nov 21, 2025
21ff900
chore: add comment regarding attrs usage
wobsoriano Nov 21, 2025
501374b
chore: simplify wrapped components
wobsoriano Nov 21, 2025
00e8303
chore: remove manual external vue
wobsoriano Nov 21, 2025
9a74c78
Merge branch 'vincent-and-the-doctor' into rob/nuxt-routing-path
wobsoriano Nov 21, 2025
4cc101c
chore: improve comment around SignIn.vue hack
wobsoriano Nov 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/thirty-cherries-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@clerk/nuxt": major
---

Routing strategy for the ff. components now default to `path`:

- `<SignIn />`
- `<SignUp />`
- `<UserProfile />`
- `<OrganizationProfile />`
- `<CreateOrganization />`
- `<OrganizationList />`
5 changes: 5 additions & 0 deletions .changeset/wild-bees-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/vue": minor
---

Introduced internal composable for handling routing configuration for UI components
6 changes: 3 additions & 3 deletions integration/templates/nuxt-node/app/middleware/auth.global.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
export default defineNuxtRouteMiddleware(to => {
const { userId } = useAuth();

const isPublicPage = createRouteMatcher(['/sign-in']);
const isProtectedPage = createRouteMatcher(['/user']);
const isPublicPage = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)']);
const isProtectedPage = createRouteMatcher(['/user-profile(.*)']);

// Is authenticated and trying to access a public page
if (userId.value && isPublicPage(to)) {
return navigateTo('/user');
return navigateTo('/user-profile');
}

// Is not authenticated and trying to access a protected page
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<SignIn routing="hash" />
</template>
3 changes: 0 additions & 3 deletions integration/templates/nuxt-node/app/pages/sign-in.vue

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<SignIn signUpUrl="/sign-up" />
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<SignUp signInUrl="/sign-in" />
</template>
19 changes: 2 additions & 17 deletions integration/tests/nuxt/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te
await u.page.context().clearCookies();
});

test('sign in with hash routing', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.goToRelative('/sign-in');
await u.po.signIn.waitForMounted();

await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForURL(`${app.serverUrl}/sign-in#/factor-one`);

await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
});

test('render user profile with SSR data', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

Expand All @@ -54,7 +39,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te
await u.po.expect.toBeSignedIn();

await u.po.userButton.waitForMounted();
await u.page.goToRelative('/user');
await u.page.goToRelative('/user-profile');
await u.po.userProfile.waitForMounted();

// Fetched from an API endpoint (/api/me), which is server-rendered.
Expand All @@ -66,7 +51,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te
test('redirects to sign-in when unauthenticated', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.goToRelative('/user');
await u.page.goToRelative('/user-profile');
await u.page.waitForURL(`${app.serverUrl}/sign-in`);
await u.po.signIn.waitForMounted();
});
Expand Down
76 changes: 76 additions & 0 deletions integration/tests/nuxt/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../../presets';
import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('navigation modes @nuxt', ({ app }) => {
test.describe.configure({ mode: 'serial' });
let fakeUser: FakeUser;

test.beforeAll(async () => {
const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser();
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('sign in with path routing', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();

await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForURL(`${app.serverUrl}/sign-in/factor-one`);

await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();

await u.po.expect.toBeSignedIn();
});

test('sign in with hash routing', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.goToRelative('/hash/sign-in');
await u.po.signIn.waitForMounted();

await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForURL(`${app.serverUrl}/hash/sign-in#/factor-one`);

await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
});

test('sign in with path routing navigates to previous page', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();

await u.po.signIn.getGoToSignUp().click();
await u.po.signUp.waitForMounted();
await u.page.waitForURL(`${app.serverUrl}/sign-up`);

await page.goBack();
await u.po.signIn.waitForMounted();
await u.page.waitForURL(`${app.serverUrl}/sign-in`);
});

test('user profile uses path routing', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/user-profile/security');
await expect(u.page.locator('.cl-headerTitle').filter({ hasText: 'Security' })).toBeVisible();
});
});
3 changes: 2 additions & 1 deletion packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
},
"devDependencies": {
"nuxt": "^4.1.2",
"typescript": "catalog:repo"
"typescript": "catalog:repo",
"vue": "catalog:repo"
},
"engines": {
"node": ">=20.9.0"
Expand Down
31 changes: 21 additions & 10 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,28 @@ export default defineNuxtModule<ModuleOptions>({
},
]);

// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const components: Array<keyof typeof import('@clerk/vue')> = [
// Authentication Components
// Components that use path-based routing (wrapped components)
const wrappedComponents = [
'SignIn',
'SignUp',
'UserProfile',
'OrganizationProfile',
'CreateOrganization',
'OrganizationList',
] as const;

wrappedComponents.forEach(component => {
void addComponent({
name: component,
export: component,
filePath: resolver.resolve('./runtime/components'),
});
});

// Other components exported directly from @clerk/vue
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const otherComponents: Array<keyof typeof import('@clerk/vue')> = [
// Authentication Components
'GoogleOneTap',
// Unstyled Components
'SignInButton',
Expand All @@ -150,12 +167,8 @@ export default defineNuxtModule<ModuleOptions>({
'SignInWithMetamaskButton',
// User Components
'UserButton',
'UserProfile',
// Organization Components
'CreateOrganization',
'OrganizationProfile',
'OrganizationSwitcher',
'OrganizationList',
// Billing Components
'PricingTable',
// Control Components
Expand All @@ -170,10 +183,8 @@ export default defineNuxtModule<ModuleOptions>({
'SignedIn',
'SignedOut',
'Waitlist',
// API Keys Components
'APIKeys',
];
components.forEach(component => {
otherComponents.forEach(component => {
void addComponent({
name: component,
export: component,
Expand Down
10 changes: 2 additions & 8 deletions packages/nuxt/src/runtime/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
export { SignIn, SignUp, UserProfile, OrganizationProfile, CreateOrganization, OrganizationList } from './uiComponents';

export {
// UI components
SignUp,
SignIn,
UserProfile,
UserAvatar,
UserButton,
OrganizationSwitcher,
OrganizationProfile,
CreateOrganization,
OrganizationList,
GoogleOneTap,
Waitlist,
// Control components
Expand All @@ -30,5 +25,4 @@ export {
SignOutButton,
SignInWithMetamaskButton,
PricingTable,
APIKeys,
} from '@clerk/vue';
77 changes: 77 additions & 0 deletions packages/nuxt/src/runtime/components/uiComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
CreateOrganization as BaseCreateOrganization,
OrganizationList as BaseOrganizationList,
OrganizationProfile as BaseOrganizationProfile,
SignIn as BaseSignIn,
SignUp as BaseSignUp,
UserProfile as BaseUserProfile,
} from '@clerk/vue';
import { useRoutingProps } from '@clerk/vue/internal';
import { useRoute } from 'nuxt/app';
import { type Component, computed, defineComponent, h } from 'vue';

const usePathnameWithoutSplatRouteParams = () => {
const route = useRoute();

// Get the pathname without catch-all route params
return computed(() => {
const pathname = route.path || '';

// Find catch-all params (they are arrays in Nuxt)
const catchAllSegments = Object.values(route.params || {})
.filter((v): v is string[] => Array.isArray(v))
.flat();

// If no catch-all segments, return the pathname as-is
if (catchAllSegments.length === 0) {
return pathname || '/';
}

// Get the splat route param (join array segments into a string)
// eg ["world"] becomes "/world"
const splatRouteParam = '/' + catchAllSegments.join('/');

// Remove the splat route param from the pathname
// so we end up with the pathname where the components are mounted at
// eg /hello/world with slug=["world"] returns /hello
const path = pathname.replace(splatRouteParam, '').replace(/\/$/, '').replace(/^\//, '').trim();

return `/${path}`;
});
};

/**
* Helper function to wrap a Vue component with routing logic while preserving the base component's type.
* The type assertion is hidden inside the function, so the public API can use `typeof BaseComponent`.
*/
const wrapComponentWithRouting = <T extends Component>(baseComponent: T, componentName: string): T => {
return defineComponent((_, { attrs, slots }) => {
const path = usePathnameWithoutSplatRouteParams();
const routingProps = useRoutingProps(
componentName,
() => attrs,
() => ({ path: path.value }),
);
return () => h(baseComponent, routingProps.value, slots);
}) as T;
};

const _UserProfile = wrapComponentWithRouting(BaseUserProfile, 'UserProfile');
export const UserProfile = Object.assign(_UserProfile, {
Page: BaseUserProfile.Page,
Link: BaseUserProfile.Link,
});

const _OrganizationProfile = wrapComponentWithRouting(BaseOrganizationProfile, 'OrganizationProfile');
export const OrganizationProfile = Object.assign(_OrganizationProfile, {
Page: BaseOrganizationProfile.Page,
Link: BaseOrganizationProfile.Link,
});

export const CreateOrganization = wrapComponentWithRouting(BaseCreateOrganization, 'CreateOrganization');

export const OrganizationList = wrapComponentWithRouting(BaseOrganizationList, 'OrganizationList');

export const SignIn = wrapComponentWithRouting(BaseSignIn, 'SignIn');

export const SignUp = wrapComponentWithRouting(BaseSignUp, 'SignUp');
2 changes: 1 addition & 1 deletion packages/nuxt/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default defineConfig(() => {
entry: [
'./src/module.ts',
'./src/runtime/plugin.ts',
'./src/runtime/components/index.ts',
'./src/runtime/components/*.ts',
'./src/runtime/composables/index.ts',
'./src/runtime/client/*.ts',
'./src/runtime/server/*.ts',
Expand Down
6 changes: 3 additions & 3 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vue.ts/tsx-auto-props": "^0.6.0",
"unplugin-vue": "^6.2.0",
"vue": "3.5.21",
"vue-tsc": "^2.2.12"
"unplugin-vue": "^7.0.8",
"vue": "catalog:repo",
"vue-tsc": "^3.1.4"
},
"peerDependencies": {
"vue": "^3.2.0"
Expand Down
19 changes: 14 additions & 5 deletions packages/vue/src/components/ui-components/SignIn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,29 @@
import { ClerkHostRenderer } from '../ClerkHostRenderer';
import { useClerk } from '../../composables';
import type { SignInProps } from '@clerk/shared/types';
import { getCurrentInstance } from 'vue';

const clerk = useClerk();

const props = withDefaults(defineProps<SignInProps>(), {
transferable: undefined,
withSignUp: undefined,
});
defineProps<SignInProps>();

// Hacking our way to get actual initial uncasted boolean props as vue-tsc
// is having a hard time with SignInProps type and withDefaults()
const currentInstance = getCurrentInstance();

const hasInitialTransferable = 'transferable' in (currentInstance?.vnode.props ?? {});
const hasInitialWithSignUp = 'withSignUp' in (currentInstance?.vnode.props ?? {});
</script>

<template>
<ClerkHostRenderer
:mount="clerk?.mountSignIn"
:unmount="clerk?.unmountSignIn"
:props="props"
:props="{
...$props,
transferable: hasInitialTransferable ? $props.transferable : undefined,
withSignUp: hasInitialWithSignUp ? $props.withSignUp : undefined,
}"
:update-props="(clerk as any)?.__unstable__updateProps"
/>
</template>
Loading
Loading