Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Allow self-hosted operators to inject custom client-side scripts via environment variables.",
"domain": "core",
"bullet_points": [],
"created_at": "2026-04-21"
}
1 change: 1 addition & 0 deletions docker-compose.no-caddy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ services:
DOWNLOAD_FILE_VIA_XHR:
BASEROW_DISABLE_GOOGLE_DOCS_FILE_PREVIEW:
BASEROW_DISABLE_SUPPORT:
BASEROW_EXTRA_CLIENT_SCRIPT_URLS:
HOURS_UNTIL_TRASH_PERMANENTLY_DELETED:
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
FEATURE_FLAGS:
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ services:
DOWNLOAD_FILE_VIA_XHR:
BASEROW_DISABLE_GOOGLE_DOCS_FILE_PREVIEW:
BASEROW_DISABLE_SUPPORT:
BASEROW_EXTRA_CLIENT_SCRIPT_URLS:
HOURS_UNTIL_TRASH_PERMANENTLY_DELETED:
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
FEATURE_FLAGS:
Expand Down
1 change: 1 addition & 0 deletions docs/installation/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ domain than your Baserow, you need to make sure CORS is configured correctly.
| BASEROW\_BUILDER\_DOMAINS | A comma separated list of domain names that can be used as the domains to create sub domains in the application builder. | |
| BASEROW\_FRONTEND\_SAME\_SITE\_COOKIE | String value indicating what the sameSite value of the created cookies should be. | lax |
| BASEROW\_DISABLE\_SUPPORT | Set to any value to disable the support features in Baserow. | |
| BASEROW\_EXTRA\_CLIENT\_SCRIPT\_URLS | A comma-separated list of URLs pointing to externally hosted JavaScript files that can be used to customize the frontend application. Requires an active enterprise license. | |


### SSO Configuration
Expand Down
121 changes: 121 additions & 0 deletions docs/plugins/custom-client-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Custom Client Scripts

> **Enterprise feature** -- requires an active enterprise license.

The `BASEROW_EXTRA_CLIENT_SCRIPT_URLS` environment variable lets self-hosted operators
inject custom client-side JavaScript into every page without building a full plugin.
Scripts are loaded in the `<head>` and execute before the application hydrates.

## Configuration

Set the environment variable to one or more comma-separated URLs pointing to your
scripts:

```bash
BASEROW_EXTRA_CLIENT_SCRIPT_URLS=https://example.com/my-script.js
```

Multiple scripts:

```bash
BASEROW_EXTRA_CLIENT_SCRIPT_URLS=https://example.com/analytics.js,https://example.com/banner.js
```

## The `window.__baserow` API

Before your scripts execute, Baserow creates a `window.__baserow` global with the
following properties:

| Property | Description |
|---|---|
| `window.__baserow.config` | The public runtime configuration object (read-only). |
| `window.__baserow.hook(name, fn)` | Register a callback for a Nuxt lifecycle hook. |
| `window.__baserow.$router` | The Vue Router instance (available after the app plugin runs). |

### Registering hooks

Use `window.__baserow.hook()` to run code at specific lifecycle moments. Hooks
registered before the Nuxt app is ready are queued and replayed automatically once the
app finishes loading.

```js
// Log when the application has mounted
window.__baserow.hook('app:mounted', () => {
console.log('Baserow has mounted')
})
```

```js
// Access runtime config inside a hook
window.__baserow.hook('app:mounted', () => {
const config = window.__baserow.config
console.log('Public URL:', config.PUBLIC_WEB_FRONTEND_URL)
})
```

```js
// Use the router to react to navigation
window.__baserow.hook('app:mounted', () => {
window.__baserow.$router.afterEach((to) => {
console.log('Navigated to', to.fullPath)
})
})
```

## Examples

### Inject a third-party analytics snippet

```js
window.__baserow.hook('app:mounted', () => {
const script = document.createElement('script')
script.src = 'https://analytics.example.com/tracker.js'
script.async = true
document.head.appendChild(script)
})
```

### Show a maintenance banner

```js
window.__baserow.hook('app:mounted', () => {
const banner = document.createElement('div')
banner.textContent = 'Scheduled maintenance tonight at 22:00 UTC'
banner.style.cssText =
'position:fixed;top:0;left:0;right:0;padding:8px;background:#f59e0b;' +
'color:#000;text-align:center;z-index:9999;font-size:14px;'
document.body.prepend(banner)
})
```

### Track page views

```js
window.__baserow.hook('app:mounted', () => {
const track = (path) => {
fetch('https://analytics.example.com/collect', {
method: 'POST',
body: JSON.stringify({ path, ts: Date.now() }),
headers: { 'Content-Type': 'application/json' },
})
}

// Initial page
track(window.location.pathname)

// Subsequent navigations
window.__baserow.$router.afterEach((to) => {
track(to.fullPath)
})
})
```

## Notes

* Scripts are only loaded when the enterprise license includes the
`ENTERPRISE_SETTINGS` feature.
* The `hook()` function accepts any
[Nuxt lifecycle hook name](https://nuxt.com/docs/api/advanced/hooks#app-hooks-runtime).
`app:mounted` is the most common choice for DOM manipulation.
* Because scripts run in the browser, they cannot access backend secrets or server-side
configuration.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
useHead,
useNuxtApp,
useRequestEvent,
useRuntimeConfig,
} from '#imports'
import EnterpriseFeatures from '@baserow_enterprise/features'

const getUrls = (raw) =>
raw
.split(',')
.map((value) => value.trim())
.filter(Boolean)

const bootstrapScript = (config) => `
window.__baserow = window.__baserow || {};
window.__baserow.config = ${JSON.stringify(config).replace(/</g, '\\u003c')};
window.__baserow._queuedHooks = window.__baserow._queuedHooks || [];
window.__baserow.hook = function(name, fn) {
window.__baserow._queuedHooks.push([name, fn]);
};
`

export default defineNuxtRouteMiddleware(async () => {
if (import.meta.client) return

const event = import.meta.server ? useRequestEvent() : null

if (import.meta.server && !event) return

const runtimeConfig = useRuntimeConfig()
const raw = runtimeConfig.public.baserowExtraClientScriptUrls
if (!raw) return

const urls = getUrls(raw)
if (urls.length === 0) return

const nuxtApp = useNuxtApp()
const store = nuxtApp.$store

// This runs on SSR so entitled extra scripts can be emitted into the initial HTML
// head and execute before hydration/first paint. We must gate here, per request,
// because module-level head injection would bypass the ENTERPRISE_SETTINGS check and
// load the scripts for every visitor.
if (!store.getters['settings/isLoaded']) {
await store.dispatch('settings/load')
}

if (!nuxtApp.$hasFeature(EnterpriseFeatures.ENTERPRISE_SETTINGS)) {
return
}

useHead({
script: [
{
key: 'baserow-extra-client-script-bootstrap',
innerHTML: bootstrapScript(runtimeConfig.public),
tagPosition: 'head',
},
...urls.map((src, index) => ({
key: `baserow-extra-client-script-${index}`,
src,
tagPosition: 'head',
'data-baserow-extra-client-script': 'true',
})),
],
})
})
12 changes: 12 additions & 0 deletions enterprise/web-frontend/modules/baserow_enterprise/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
addPlugin,
createResolver,
extendPages,
addRouteMiddleware,
} from 'nuxt/kit'
import { routes, rootChildRoutes } from './routes'
import { locales } from '../../../../web-frontend/config/locales.js'
Expand Down Expand Up @@ -72,12 +73,23 @@ export default defineNuxtModule({
src: resolve('./plugin.js'),
})

addPlugin({
src: resolve('./plugins/extraClientScriptUrls.js'),
})

addRouteMiddleware({
name: 'enterpriseExtraClientScripts',
path: resolve('./middleware/extraClientScripts'),
global: true,
})

// Runtime config defaults - values can be overridden at runtime via NUXT_ prefixed env vars
// See env-remap.mjs for the env var remapping that enables backwards compatibility
nuxt.options.runtimeConfig.public = _.defaultsDeep(
nuxt.options.runtimeConfig.public,
{
baserowEnterpriseAssistantLlmModel: '',
baserowExtraClientScriptUrls: '',
}
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useNuxtApp, useRuntimeConfig } from '#imports'

export default defineNuxtPlugin({
name: 'enterprise-extra-client-script-runtime',
dependsOn: ['enterprise'],
setup() {
if (!import.meta.client) return

if (!window.__baserow) return

const nuxtApp = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const queuedHooks = window.__baserow._queuedHooks || []

window.__baserow = {
...window.__baserow,
$router: nuxtApp.$router,
config: runtimeConfig.public,
hook: (name, fn) => nuxtApp.hook(name, fn),
}

for (const [name, fn] of queuedHooks) {
nuxtApp.hook(name, fn)
}

delete window.__baserow._queuedHooks
},
})
3 changes: 1 addition & 2 deletions web-frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ WORKDIR /baserow/web-frontend

# Build, then clean reinstall production only
RUN --mount=type=cache,target=$YARN_CACHE_FOLDER,uid=$UID,gid=$GID,sharing=locked \
yarn run build \
&& find .output -type f -name "*.map" -delete
yarn run build


# =============================================================================
Expand Down
2 changes: 2 additions & 0 deletions web-frontend/env-remap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const envMapping = {
SENTRY_DSN: 'NUXT_PUBLIC_SENTRY_DSN',
SENTRY_ENVIRONMENT: 'NUXT_PUBLIC_SENTRY_ENVIRONMENT',
MEDIA_URL: 'NUXT_PUBLIC_MEDIA_URL',
BASEROW_EXTRA_CLIENT_SCRIPT_URLS:
'NUXT_PUBLIC_BASEROW_EXTRA_CLIENT_SCRIPT_URLS',
}

// Remap env vars: only if legacy var exists AND NUXT_ var is not already set
Expand Down
Loading