Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file added assets/blog/covers/custom-org-switcher.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import starlightContextualMenu from 'starlight-contextual-menu'
import starlightThemeNova from 'starlight-theme-nova'
import starlightVideos from 'starlight-videos'
import starlightLinksValidator from 'starlight-links-validator'
import starlightBlog from 'starlight-blog'
import starlightLlmsTxt from 'starlight-llms-txt'
import { sidebar as sidebarConfig, topics, exclude } from './src/configs/sidebar.config'
import { redirects } from './src/configs/redirects.config'
Expand Down Expand Up @@ -93,6 +94,13 @@ export default defineConfig({
actions: ['copy', 'chatgpt', 'claude'],
hideMainActionLabel: true,
}),
starlightBlog({
prefix: 'cookbooks',
metrics: {
readingTime: true,
words: 'total',
},
}),
],
head: [
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"react-dom": "^19.2.4",
"repomix": "^1.11.1",
"sharp": "^0.34.5",
"starlight-blog": "^0.25.2",
"starlight-contextual-menu": "^0.1.5",
"starlight-image-zoom": "^0.12.0",
"starlight-links-validator": "^0.19.2",
Expand Down
4,024 changes: 2,702 additions & 1,322 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/configs/sidebar.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ export const sidebar = [
*/
export const exclude = [
'/', // Home page
'/cookbooks',
'/404', // Error page
'/apis/**/*', // REST API reference has Scalar-powered navigation
]
Expand Down
59 changes: 31 additions & 28 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,43 @@ import { docsSchema } from '@astrojs/starlight/schema'
import { topicSchema } from 'starlight-sidebar-topics/schema'
import { videosSchema } from 'starlight-videos/schemas'
import { githubReleasesLoader } from 'astro-loader-github-releases'
import { blogSchema } from 'starlight-blog/schema'
import { githubFilesLoader } from './loaders/github-files-loader'

export const collections = {
docs: defineCollection({
loader: docsLoader(),
schema: docsSchema({
extend: topicSchema
.merge(videosSchema)
.merge(z.object({ overviewTitle: z.string().optional() }))
.merge(
z.object({
seeAlso: z
.object({
items: z.array(
z.object({
title: z.string(),
url: z.string(),
icon: z.string().optional(),
}),
),
expanded: z.boolean().optional().default(true),
label: z.string().optional().default('See also'),
})
.optional(),
browseCentral: z
.object({
label: z.string().optional(),
filterType: z.array(z.enum(['code-sample', 'tutorial', 'video'])),
category: z.array(z.string()),
icon: z.string().optional(),
})
.optional(),
}),
),
extend: (context) =>
blogSchema(context)
.merge(topicSchema)
.merge(videosSchema)
.merge(z.object({ overviewTitle: z.string().optional() }))
.merge(
z.object({
seeAlso: z
.object({
items: z.array(
z.object({
title: z.string(),
url: z.string(),
icon: z.string().optional(),
}),
),
expanded: z.boolean().optional().default(true),
label: z.string().optional().default('See also'),
})
.optional(),
browseCentral: z
.object({
label: z.string().optional(),
filterType: z.array(z.enum(['code-sample', 'tutorial', 'video'])),
category: z.array(z.string()),
icon: z.string().optional(),
})
.optional(),
}),
),
}),
}),
// GitHub Releases - Automatically fetched from GitHub releases
Expand Down
255 changes: 255 additions & 0 deletions src/content/docs/cookbooks/building-custom-org-switcher.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
---
title: 'Building a Custom Organization Switcher'
description: 'Learn how to build your own organization switcher UI for complete control over multi-tenant user experiences.'
date: 2025-01-21
tags: ['Full stack auth']
excerpt: When users belong to multiple organizations, the default Scalekit organization switcher handles most use cases. However, some applications require deeper integration—a custom switcher embedded directly in your app's navigation, or a specialized UI that matches your design system.
featured: true
cover:
alt: 'Modern desk setup with laptop and workspace accessories'
image: ../../../../assets/blog/covers/custom-org-switcher.jpg
authors:
- name: 'Hashirr'
title: 'Hero developer'
url: 'https://www.linkedin.com/in/hashirr-lukmahn/'
picture: '/images/blog/authors/hashirr-lukmahn.jpg'
---
Comment on lines +1 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add required frontmatter fields and fix title casing/date.

Frontmatter is missing required navigation metadata (sidebar label and order), and the title should be in sentence case. Also, Line 4 uses 2025-01-21; please confirm if this should reflect the current publish date (e.g., 2026-01-21). As per coding guidelines, add tableOfContents since this page has multiple sections. As per coding guidelines, ...

🤖 Prompt for AI Agents
In `@src/content/docs/blog/building-custom-org-switcher.mdx` around lines 1 - 16,
Update the MDX frontmatter: change the Title field to sentence case (update
'title'), confirm and update the publish `date` to the intended current date if
needed, and add the required navigation metadata by adding `sidebar_label` (a
short label) and `sidebar_position` or `order` to place it in the docs
navigation; also add `tableOfContents: true` to enable TOC for multi-section
pages and ensure the existing `authors`, `tags`, `excerpt`, `featured`, and
`cover` fields remain intact (look for the frontmatter block beginning with
`---` and the keys `title`, `date`, `authors` to locate where to add these
fields).

import { TabItem, Tabs } from '@astrojs/starlight/components';

When users belong to multiple organizations, the default Scalekit organization switcher handles most use cases. However, some applications require deeper integration—a custom switcher embedded directly in your app's navigation, or a specialized UI that matches your design system.

This guide shows you how to build your own organization switcher using Scalekit's APIs.

## Why build a custom switcher?

The default Scalekit-hosted switcher works well for most scenarios. Build a custom switcher when you need:

- **In-app navigation**: Users switch organizations without leaving your application
- **Custom branding**: The switcher matches your application's design language
- **Specialized workflows**: Your app needs org-specific logic during switches
- **Reduced redirects**: Avoid sending users through the authentication flow for every switch

## How the custom switcher works

Your application handles the entire switching flow:

1. User authenticates through Scalekit and receives a session
2. Your app fetches the user's organizations via the User Sessions API
3. You render your own organization selector UI
4. When a user selects an organization, your app updates the active context

This approach gives you full control over the UI and routing, but requires you to manage session state and organization context within your application.

## Fetch user organizations

The User Sessions API returns the `authenticated_organizations` field containing all organizations the user can access. Use this data to populate your switcher UI.

<Tabs>
<TabItem value="node" label="Node.js">
```javascript title="Express.js"
// Use case: Get user's organizations for your switcher UI
// Security: Always validate session ownership before returning org data
const session = await scalekit.session.getSession(sessionId);

// Extract organizations from the session response
const organizations = session.authenticated_organizations || [];

// Render your organization switcher with this data
res.json({ organizations });
```
</TabItem>
<TabItem value="python" label="Python">
```python title="Flask"
# Use case: Get user's organizations for your switcher UI
# Security: Always validate session ownership before returning org data
session = scalekit_client.session.get_session(session_id)

# Extract organizations from the session response
organizations = session.get('authenticated_organizations', [])

# Render your organization switcher with this data
return jsonify({'organizations': organizations})
```
</TabItem>
<TabItem value="go" label="Go">
```go title="Gin"
// Use case: Get user's organizations for your switcher UI
// Security: Always validate session ownership before returning org data
session, err := scalekitClient.Session().GetSession(ctx, sessionId)
if err != nil {
return err
}

// Extract organizations from the session response
organizations := session.AuthenticatedOrganizations

// Render your organization switcher with this data
c.JSON(http.StatusOK, gin.H{"organizations": organizations})
```
</TabItem>
<TabItem value="java" label="Java">
```java title="Spring"
// Use case: Get user's organizations for your switcher UI
// Security: Always validate session ownership before returning org data
Session session = scalekitClient.sessions().getSession(sessionId);

// Extract organizations from the session response
List<Organization> organizations = session.getAuthenticatedOrganizations();

// Render your organization switcher with this data
return ResponseEntity.ok(Map.of("organizations", organizations));
```
</TabItem>
</Tabs>
Comment on lines +47 to +103
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use <Tabs syncKey="tech-stack"> for multi-language examples.

Both Tabs blocks should include syncKey="tech-stack" so language selection stays consistent across pages. This is required for MDX multi-language examples. Based on learnings, ...

🔧 Suggested update
-<Tabs>
+<Tabs syncKey="tech-stack">

Also applies to: 124-243

🤖 Prompt for AI Agents
In `@src/content/docs/blog/building-custom-org-switcher.mdx` around lines 47 -
103, Add the required syncKey attribute to the Tabs components so language
selection remains consistent: locate the Tabs elements (the top-level Tabs
component wrapping the TabItem blocks in this file) and add syncKey="tech-stack"
to each Tabs instance (the TabItem components remain unchanged); ensure both
Tabs blocks on the page use the identical syncKey value "tech-stack" so
multi-language examples stay synchronized across pages.


The response includes organization IDs, names, and metadata for each organization the user can access.

## Add domain context

Enhance your switcher by displaying which domains are associated with each organization. Use the Domains API to fetch this information.

```javascript
// Example: Fetch domains for an organization
const domains = await scalekit.domains.list({ organizationId: 'org_123' });

// Display "@acme.com" next to the organization name in your UI
```
Comment on lines +107 to +116
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Provide 4-language examples for the Domains API snippet.

The “Add domain context” block is JavaScript-only, but documentation requires Node.js, Python, Go, and Java for all code examples. Please convert this snippet to a Tabs block with all four languages. As per coding guidelines, ...

🤖 Prompt for AI Agents
In `@src/content/docs/blog/building-custom-org-switcher.mdx` around lines 107 -
116, The "Add domain context" block currently contains only a JavaScript
example; replace it with a Tabs block containing Node.js, Python, Go, and Java
examples that each call the Domains API to list domains for an organization and
show how to display a domain (e.g., "@acme.com") next to the org name.
Specifically, update the block that references scalekit.domains.list and
organizationId: 'org_123' so it includes a Node.js/JavaScript snippet (same call
pattern), a Python snippet calling scalekit.domains.list or equivalent client
method with organization_id='org_123', a Go snippet using the Domains.List
method with OrganizationID: "org_123", and a Java snippet using the
domains().list(...) call with organizationId "org_123"; keep the examples
minimal and consistent (fetch domains, iterate or access first domain, and show
"@acme.com" display note).


This helps users quickly identify the correct organization, especially when they belong to organizations with similar names.

## Handle organization selection

When a user selects an organization in your custom switcher, update your application's context. Store the active organization ID in session storage or a cookie, then use it for subsequent API calls.

<Tabs>
<TabItem value="node" label="Node.js">
```javascript title="Express.js"
// Use case: Store selected organization and fetch org-specific data
app.post('/api/select-organization', async (req, res) => {
const { organizationId } = req.body;
const sessionId = req.session.scalekitSessionId;

// Security: Verify the user belongs to this organization
const session = await scalekit.session.getSession(sessionId);
const hasAccess = session.authenticated_organizations.some(
org => org.id === organizationId
);

if (!hasAccess) {
return res.status(403).json({ error: 'Unauthorized' });
}

// Store the active organization in the user's session
req.session.activeOrganizationId = organizationId;

res.json({ success: true });
});
```
</TabItem>
<TabItem value="python" label="Python">
```python title="Flask"
# Use case: Store selected organization and fetch org-specific data
@app.route('/api/select-organization', methods=['POST'])
def select_organization():
data = request.get_json()
organization_id = data.get('organizationId')
session_id = session.get('scalekit_session_id')

# Security: Verify the user belongs to this organization
user_session = scalekit_client.session.get_session(session_id)
has_access = any(
org['id'] == organization_id
for org in user_session.get('authenticated_organizations', [])
)

if not has_access:
return jsonify({'error': 'Unauthorized'}), 403

# Store the active organization in the user's session
session['active_organization_id'] = organization_id

return jsonify({'success': True})
```
</TabItem>
<TabItem value="go" label="Go">
```go title="Gin"
// Use case: Store selected organization and fetch org-specific data
func SelectOrganization(c *gin.Context) {
var req struct {
OrganizationID string `json:"organizationId"`
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}

sessionID := c.GetString("scalekitSessionID")

// Security: Verify the user belongs to this organization
session, err := scalekitClient.Session().GetSession(ctx, sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Session error"})
return
}

hasAccess := false
for _, org := range session.AuthenticatedOrganizations {
if org.ID == req.OrganizationID {
hasAccess = true
break
}
}

if !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}

// Store the active organization in the user's session
c.SetCookie("activeOrganizationID", req.OrganizationID, 3600, "/", "", true, true)

c.JSON(http.StatusOK, gin.H{"success": true})
}
```
</TabItem>
<TabItem value="java" label="Java">
```java title="Spring"
// Use case: Store selected organization and fetch org-specific data
@PostMapping("/api/select-organization")
public ResponseEntity<?> selectOrganization(
@RequestBody Map<String, String> request,
HttpSession httpSession
) {
String organizationId = request.get("organizationId");
String sessionId = (String) httpSession.getAttribute("scalekitSessionId");

// Security: Verify the user belongs to this organization
Session session = scalekitClient.sessions().getSession(sessionId);
boolean hasAccess = session.getAuthenticatedOrganizations().stream()
.anyMatch(org -> org.getId().equals(organizationId));

if (!hasAccess) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Unauthorized"));
}

// Store the active organization in the user's session
httpSession.setAttribute("activeOrganizationId", organizationId);

return ResponseEntity.ok(Map.of("success", true));
}
```
</TabItem>
</Tabs>

Always verify that the user actually belongs to the organization they're attempting to switch to. The `authenticated_organizations` array from the session is your source of truth for access control.

## When to use the hosted switcher instead

The default Scalekit-hosted switcher is the right choice when:

- You want the quickest implementation with minimal code
- Your application doesn't require in-app organization switching
- You're okay with users navigating through the authentication flow to switch organizations

Build a custom switcher when user experience requirements demand deeper integration with your application's UI and routing.
File renamed without changes.