This guide shows how to add an Artifactory-powered “Artifacts” tab to Backstage entities. The tab displays a folder tree on the left, a details table on the right, and opens downloads in a new browser tab (so Artifactory can handle authentication).
What you’ll build
For any Backstage entity that has these annotations:
jfrog.io/artifactory-repo(required)jfrog.io/artifactory-path(optional)
Backstage will show:
- A new entity tab: Artifacts
- Left: expandable folder tree
- Right: “Children” table (Type / Filename / Download)
- Download opens a new tab (
window.open(..., "_blank"))
Prerequisites
- Backstage app repo (monorepo)
- JFrog Artifactory reachable from the Backstage backend
- An Artifactory Identity Token (recommended) or another Artifactory auth method
Note about security: the Backstage proxy will send the token to Artifactory server-side. That means any Backstage user who can access the proxy endpoint can effectively browse Artifactory as that token user, unless you add access control.
- Create a new Backstage plugin
From your Backstage repo root:
yarn new --select plugin
# name: artifactory-browser
This guide uses an entity page content component rather than a standalone routable page.
- Create an Artifactory Identity Token
In Artifactory UI:
- Go to User Profile → Identity Tokens
- Click Generate an Identity Token
- Copy the token value (you only see the full token once)
The Token ID is not the secret; you need the token value.

- Configure the Backstage proxy for Artifactory
Edit app-config.production.yaml (or the config you actually run with), and add:
proxy:
endpoints:
"/artifactory":
target: https://artifactory.maksonlee.com/artifactory
changeOrigin: true
headers:
Authorization: ${ARTIFACTORY_AUTH_HEADER}
Set the environment variable using Bearer format:
export ARTIFACTORY_AUTH_HEADER="Bearer <YOUR_IDENTITY_TOKEN_VALUE>"
Quick connectivity test
curl -sS -H "Authorization: Bearer <TOKEN>" \
https://artifactory.maksonlee.com/artifactory/api/system/ping
- Add a small frontend API client
Create: plugins/artifactory-browser/src/api.ts
import {
createApiRef,
DiscoveryApi,
FetchApi,
} from '@backstage/core-plugin-api';
export type ArtifactoryChild = {
uri: string; // e.g. "/2.2.1-next-SNAPSHOT"
folder: boolean | string; // some installs return boolean, some return "true"/"false"
};
export type ArtifactoryFolderInfo = {
repo: string;
path: string; // e.g. "/com/acme/app"
children?: ArtifactoryChild[];
created?: string;
createdBy?: string;
lastModified?: string;
modifiedBy?: string;
lastUpdated?: string;
};
export type ArtifactoryFileInfo = {
repo: string;
path: string; // e.g. "/com/acme/app/file.txt"
downloadUri: string;
created?: string;
createdBy?: string;
lastModified?: string;
modifiedBy?: string;
lastUpdated?: string;
size?: string;
mimeType?: string;
checksums?: {
md5?: string;
sha1?: string;
sha256?: string;
};
};
export interface ArtifactoryBrowserApi {
getFolderInfo(
repo: string,
folderPath: string,
): Promise<ArtifactoryFolderInfo>;
getFileInfo(repo: string, filePath: string): Promise<ArtifactoryFileInfo>;
}
export const artifactoryBrowserApiRef = createApiRef<ArtifactoryBrowserApi>({
id: 'plugin.artifactory-browser.service',
});
function normalizePath(p: string) {
const trimmed = (p ?? '').trim();
return trimmed.replace(/^\/+/, '').replace(/\/+$/, '');
}
function encodePath(p: string) {
const norm = normalizePath(p);
if (!norm) return '';
return norm.split('/').map(encodeURIComponent).join('/');
}
export class ArtifactoryBrowserClient implements ArtifactoryBrowserApi {
constructor(
private readonly deps: { discoveryApi: DiscoveryApi; fetchApi: FetchApi },
) {}
private async proxyBase() {
// -> https://backstage.../api/proxy
return this.deps.discoveryApi.getBaseUrl('proxy');
}
async getFolderInfo(repo: string, folderPath: string) {
const base = await this.proxyBase();
const full = encodePath(folderPath);
const url =
full.length > 0
? `${base}/artifactory/api/storage/${encodeURIComponent(repo)}/${full}`
: `${base}/artifactory/api/storage/${encodeURIComponent(repo)}`;
const resp = await this.deps.fetchApi.fetch(url);
if (!resp.ok) {
throw new Error(`FolderInfo failed: ${resp.status} ${await resp.text()}`);
}
return (await resp.json()) as ArtifactoryFolderInfo;
}
async getFileInfo(repo: string, filePath: string) {
const base = await this.proxyBase();
const full = encodePath(filePath);
const url = `${base}/artifactory/api/storage/${encodeURIComponent(
repo,
)}/${full}`;
const resp = await this.deps.fetchApi.fetch(url);
if (!resp.ok) {
throw new Error(`FileInfo failed: ${resp.status} ${await resp.text()}`);
}
return (await resp.json()) as ArtifactoryFileInfo;
}
}
- Build the UI: tree on the left, file list on the right
Create:
plugins/artifactory-browser/src/components/EntityArtifactoryBrowserContent/EntityArtifactoryBrowserContent.tsx
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { EmptyState, ErrorPanel, Progress } from '@backstage/core-components';
import {
Grid,
Paper,
Typography,
Divider,
Button,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from '@material-ui/core';
import { TreeView, TreeItem } from '@material-ui/lab';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import {
artifactoryBrowserApiRef,
ArtifactoryFolderInfo,
ArtifactoryFileInfo,
} from '../../api';
type NodeKind = 'folder' | 'file';
type NodeState = {
kind: NodeKind;
loaded: boolean;
children?: Array<{ name: string; kind: NodeKind; fullPath: string }>;
};
const LOADING_SUFFIX = '::__loading';
const isFolderChild = (folder: boolean | string) =>
folder === true || folder === 'true';
function joinPath(parent: string, childUri: string) {
const child = childUri.replace(/^\/+/, '');
const p = (parent ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
return p ? `${p}/${child}` : child;
}
export const EntityArtifactoryBrowserContent = () => {
const api = useApi(artifactoryBrowserApiRef);
const { entity } = useEntity();
const repo = entity.metadata.annotations?.['jfrog.io/artifactory-repo'] ?? '';
const rootPath =
entity.metadata.annotations?.['jfrog.io/artifactory-path'] ?? '';
// TreeView nodeId should not be empty string
const ROOT_ID = '__root__';
const rootNodeId = useMemo(() => (rootPath ? rootPath : ROOT_ID), [rootPath]);
const nodeIdToPath = useCallback(
(nodeId: string) => (nodeId === ROOT_ID ? '' : nodeId),
[],
);
const [nodes, setNodes] = useState<Record<string, NodeState>>({
[rootNodeId]: { kind: 'folder', loaded: false },
});
const [expanded, setExpanded] = useState<string[]>([rootNodeId]);
const [selected, setSelected] = useState<string>(rootNodeId);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<Error | undefined>(undefined);
const [folderInfo, setFolderInfo] = useState<
ArtifactoryFolderInfo | undefined
>(undefined);
const [fileInfo, setFileInfo] = useState<ArtifactoryFileInfo | undefined>(
undefined,
);
const ensureFolderLoaded = useCallback(
async (folderNodeId: string) => {
if (String(folderNodeId).endsWith(LOADING_SUFFIX)) return;
const cur = nodes[folderNodeId];
if (cur?.kind === 'folder' && cur.loaded) return;
const folderPath = nodeIdToPath(folderNodeId);
const info = await api.getFolderInfo(repo, folderPath);
const children = (info.children ?? []).map(c => {
const kind: NodeKind = isFolderChild(c.folder) ? 'folder' : 'file';
const fullPath = joinPath(folderPath, c.uri); // actual path (no leading slash)
const childNodeId = fullPath === '' ? ROOT_ID : fullPath;
return { name: c.uri.replace(/^\/+/, ''), kind, fullPath: childNodeId };
});
// folders first, then files, alphabetical
children.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1;
return a.name.localeCompare(b.name);
});
setNodes(prev => {
const next: Record<string, NodeState> = { ...prev };
next[folderNodeId] = { kind: 'folder', loaded: true, children };
for (const ch of children) {
if (!next[ch.fullPath]) {
next[ch.fullPath] = { kind: ch.kind, loaded: ch.kind === 'file' };
}
}
return next;
});
},
[api, repo, nodes, nodeIdToPath, ROOT_ID],
);
const loadSelectionDetails = useCallback(
async (nodeId: string) => {
if (String(nodeId).endsWith(LOADING_SUFFIX)) return;
setErr(undefined);
setLoading(true);
setFolderInfo(undefined);
setFileInfo(undefined);
try {
const kind = nodes[nodeId]?.kind ?? 'folder';
const path = nodeIdToPath(nodeId);
if (kind === 'folder') {
const info = await api.getFolderInfo(repo, path);
setFolderInfo(info);
await ensureFolderLoaded(nodeId);
} else {
const info = await api.getFileInfo(repo, path);
setFileInfo(info);
}
} catch (e: any) {
setErr(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
},
[api, repo, nodes, ensureFolderLoaded, nodeIdToPath],
);
const openNode = useCallback(
(nodeId: string, kind: NodeKind) => {
setSelected(nodeId);
if (kind === 'folder') {
setExpanded(prev => (prev.includes(nodeId) ? prev : [...prev, nodeId]));
}
loadSelectionDetails(nodeId);
},
[loadSelectionDetails],
);
const handleDownload = useCallback(
async (fileNodeId: string) => {
setErr(undefined);
setLoading(true);
try {
const path = nodeIdToPath(fileNodeId);
const info = await api.getFileInfo(repo, path);
// Open Artifactory download URL in a new tab
window.open(info.downloadUri, '_blank', 'noopener,noreferrer');
} catch (e: any) {
setErr(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
},
[api, repo, nodeIdToPath],
);
useEffect(() => {
if (!repo) return;
loadSelectionDetails(rootNodeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repo, rootNodeId]);
const onToggle = async (_event: any, nodeIds: string[]) => {
const filtered = nodeIds.filter(id => !String(id).endsWith(LOADING_SUFFIX));
setExpanded(filtered);
const newlyExpanded = filtered.filter(
id => (nodes[id]?.kind ?? 'folder') === 'folder' && !nodes[id]?.loaded,
);
if (newlyExpanded.length === 0) return;
setErr(undefined);
setLoading(true);
try {
for (const id of newlyExpanded) {
await ensureFolderLoaded(id);
}
} catch (e: any) {
setErr(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
};
const onSelect = async (_event: any, nodeId: string) => {
if (String(nodeId).endsWith(LOADING_SUFFIX)) return;
setSelected(nodeId);
await loadSelectionDetails(nodeId);
};
const renderTree = (nodeId: string) => {
const node = nodes[nodeId];
const label =
nodeId === rootNodeId
? rootPath
? `${repo}/${rootPath}`
: repo
: nodeId.split('/').slice(-1)[0];
if (!node) {
return (
<TreeItem key={nodeId} nodeId={nodeId} label={label}>
<TreeItem nodeId={`${nodeId}${LOADING_SUFFIX}`} label="Loading..." />
</TreeItem>
);
}
if (node.kind === 'file') {
return <TreeItem key={nodeId} nodeId={nodeId} label={label} />;
}
const folderChildren = (node.children ?? []).filter(
c => c.kind === 'folder',
);
return (
<TreeItem key={nodeId} nodeId={nodeId} label={label}>
{node.loaded ? (
folderChildren.length > 0 ? (
folderChildren.map(c => renderTree(c.fullPath))
) : null
) : (
<TreeItem nodeId={`${nodeId}${LOADING_SUFFIX}`} label="Loading..." />
)}
</TreeItem>
);
};
if (!repo) {
return (
<EmptyState
title="Artifactory is not configured for this entity"
description="Add annotations: jfrog.io/artifactory-repo and (optional) jfrog.io/artifactory-path"
missing="info"
/>
);
}
const children = nodes[selected]?.children ?? [];
return (
<Grid container spacing={2}>
<Grid item xs={4}>
<Paper style={{ padding: 12, height: '70vh', overflow: 'auto' }}>
<Typography variant="h6">Artifacts</Typography>
<Typography variant="body2" color="textSecondary">
Source: Artifactory · Repo: {repo}
{rootPath ? ` · Path: ${rootPath}` : ''}
</Typography>
<Divider style={{ margin: '12px 0' }} />
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
expanded={expanded}
selected={selected}
onNodeToggle={onToggle}
onNodeSelect={onSelect}
>
{renderTree(rootNodeId)}
</TreeView>
</Paper>
</Grid>
<Grid item xs={8}>
<Paper style={{ padding: 12, height: '70vh', overflow: 'auto' }}>
<Typography variant="h6">Details</Typography>
<Divider style={{ margin: '12px 0' }} />
{loading && <Progress />}
{err && <ErrorPanel error={err} />}
{!loading && !err && folderInfo && (
<>
<Typography variant="subtitle1">
Folder: {folderInfo.repo}
{folderInfo.path}
</Typography>
<Divider style={{ margin: '12px 0' }} />
<Typography variant="subtitle2" style={{ marginBottom: 8 }}>
Children
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell style={{ width: 120 }}>Type</TableCell>
<TableCell>Filename</TableCell>
<TableCell style={{ width: 140 }}>Download</TableCell>
</TableRow>
</TableHead>
<TableBody>
{children.map(ch => (
<TableRow hover key={ch.fullPath}>
<TableCell>{ch.kind}</TableCell>
<TableCell>
<Button
color="primary"
size="small"
style={{
textTransform: 'none',
padding: 0,
minWidth: 0,
}}
onClick={() => openNode(ch.fullPath, ch.kind)}
>
{ch.name}
</Button>
</TableCell>
<TableCell>
{ch.kind === 'file' ? (
<Button
variant="outlined"
size="small"
onClick={() => handleDownload(ch.fullPath)}
>
Download
</Button>
) : (
<span />
)}
</TableCell>
</TableRow>
))}
{children.length === 0 && (
<TableRow>
<TableCell colSpan={3}>
<Typography variant="body2" color="textSecondary">
Empty folder
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
{!loading && !err && fileInfo && (
<>
<Typography variant="subtitle1">
File: {fileInfo.repo}
{fileInfo.path}
</Typography>
<Divider style={{ margin: '12px 0' }} />
<Typography variant="body2">Size: {fileInfo.size ?? '—'}</Typography>
<Typography variant="body2">MIME: {fileInfo.mimeType ?? '—'}</Typography>
<Typography variant="body2">
SHA256: {fileInfo.checksums?.sha256 ?? '—'}
</Typography>
<Divider style={{ margin: '12px 0' }} />
<Button
variant="contained"
color="primary"
onClick={() =>
window.open(fileInfo.downloadUri, '_blank', 'noopener,noreferrer')
}
>
Download (via Artifactory)
</Button>
</>
)}
</Paper>
</Grid>
</Grid>
);
};
- Export the plugin pieces
Update: plugins/artifactory-browser/src/index.ts
export { artifactoryBrowserApiRef, ArtifactoryBrowserClient } from './api';
export { EntityArtifactoryBrowserContent } from './components/EntityArtifactoryBrowserContent/EntityArtifactoryBrowserContent';
Update: plugins/artifactory-browser/src/plugin.ts
import { createPlugin } from '@backstage/core-plugin-api';
export const artifactoryBrowserPlugin = createPlugin({
id: 'artifactory-browser',
});
- Register the API client in the Backstage app
Edit: packages/app/src/apis.ts
Add the imports:
import { fetchApiRef } from '@backstage/core-plugin-api';
import {
artifactoryBrowserApiRef,
ArtifactoryBrowserClient,
} from '@internal/backstage-plugin-artifactory-browser';
Add the API factory:
createApiFactory({
api: artifactoryBrowserApiRef,
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ discoveryApi, fetchApi }) =>
new ArtifactoryBrowserClient({ discoveryApi, fetchApi }),
}),
- Add the “Artifacts” tab to entity pages
Edit: packages/app/src/components/catalog/EntityPage.tsx
Add the import:
import { EntityArtifactoryBrowserContent } from '@internal/backstage-plugin-artifactory-browser';
Add a helper:
const isArtifactoryAvailable = (entity: any) =>
Boolean(entity?.metadata?.annotations?.['jfrog.io/artifactory-repo']);
Then add a route to your entity layouts (service/website/default) in each place you want it:
<EntityLayout.Route
path="/artifactory"
title="Artifacts"
if={isArtifactoryAvailable}
>
<EntityArtifactoryBrowserContent />
</EntityLayout.Route>
You do not need a standalone app route like /artifactory-browser for this approach—this is meant to live inside the entity page.
- Annotate an entity to enable the tab
In the entity’s catalog-info.yaml (or wherever you define it), add:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-service
annotations:
jfrog.io/artifactory-repo: android-snapshots
jfrog.io/artifactory-path: com/acme/my-service
spec:
type: service
owner: user:default/you
jfrog.io/artifactory-repois requiredjfrog.io/artifactory-pathis optional (defaults to repo root)
- Run and verify
Start Backstage as you normally do (make sure ARTIFACTORY_AUTH_HEADER is set in the environment where the backend runs):
yarn --cwd packages/backend start \
--config ../../app-config.yaml \
--config ../../app-config.production.yaml
Go to your entity → Artifacts tab, then:
- Expand folders in the left tree
- Click a folder → file list appears on the right
- Click Download → opens a new tab to Artifactory download URL

Did this guide save you time?
Support this site