Free Screenly Player with every Business or Enterprise yearly plan. How it works →

The Edge Apps library

Scaffold an edge app with 1 command, build it with npm or Bun, and use the library's helpers for theming, error reporting, caching, and screenshots.

Updated

@screenly/edge-apps is the library behind most edge apps: a build setup, a local CORS proxy, web components such as <auto-scaler> and <app-header>, and helpers for the things every app on a screen has to handle. Its commands run as edge-apps-scripts.

It runs on Node.js 20.6.0 or later with npm, or on Bun. Both read the same package.json scripts.

Start a project

npx @screenly/edge-apps create my-edge-app
cd my-edge-app
npm run dev

With Bun, run bunx @screenly/edge-apps create my-edge-app. The command detects which package manager started it and writes the scripts to match.

The new directory has:

  • a screenly.yml manifest with a message setting and a sentry_dsn setting
  • index.html with <auto-scaler> and <app-header> already on the page
  • src/main.ts, which applies the theme, reads the message setting, and signals ready
  • Tailwind CSS set up for styling
  • scripts for dev (with the CORS proxy running alongside), build, lint, format, type-check, screenshots, and deploy
  • a Playwright spec under e2e/ for screenshots at every supported resolution

Dependencies are installed for you.

OptionWhat it does
--description <text>Sets the description in package.json, screenly.yml, and README.md
--author <text>Sets the author in package.json
--pm <npm|bun>Picks the package manager instead of detecting it
--forceWrites into a directory that already has files in it
--skip-installSkips installing dependencies

edge-apps-scripts create with no directory fills in the {{APP_NAME}}-style placeholders in the current project instead, for a template repository you've already cloned.

Scripts

The edge-apps-scripts commands are dev, build, build:dev, lint, type-check, screenshots, cors-proxy, and create. dev, build, and build:dev run on Vite 8, so a vite.config.ts or Vite plugins of your own need to work with Vite 8.

The deploy script builds and then deploys the dist/ folder:

npm run build && screenly edge-app deploy --path=dist/

The CORS proxy

During development, an API that blocks browser requests can be reached through a local proxy:

{
  "scripts": {
    "cors-proxy-server": "edge-apps-scripts cors-proxy"
  }
}

It listens on 0.0.0.0:8080, and HOST and PORT change that. Put the target URL after the proxy's address: http://localhost:8080/https://api.example.com/feed.

For GET and HEAD requests the proxy follows redirects, up to 10 deep, and then returns a 508 with Too many redirects. Other methods get the redirect response itself.

Styling with Tailwind CSS

The Tailwind plugin is part of the build. To use it in an app you didn't create with create, put these at the top of your stylesheet:

@layer theme, base, utilities;

@import 'tailwindcss/theme.css' layer(theme);
@import '@screenly/edge-apps/styles' layer(base);
@import 'tailwindcss/utilities.css' layer(utilities);

Keep the layer order line and the layer(base), or the library's base styles win over your utility classes. Don't use @import 'tailwindcss'; on its own: its reset strips the borders and padding that components like <app-header> set for themselves.

Inside <auto-scaler>, size things with h-full and w-full rather than h-screen and w-screen. <auto-scaler> draws your app at a reference size and scales it to fit the display, so viewport units measure the wrong box.

<app-header> shows the logo without the screen name. Use <brand-logo show-name> where an app needs the name on screen.

Helpers

Readable text on any brand color

Each workspace sets its own accent color, so the same app has to put readable text on a pale brand and a deep one. isLightColor() returns true when a color needs dark text:

import { isLightColor, setupTheme } from '@screenly/edge-apps'

const { primary } = setupTheme()
document.body.classList.toggle('on-light-brand', isLightColor(primary))

It uses WCAG relative luminance. It accepts hex (#abc, #aabbcc, #aabbccdd) and numeric rgb() or rgba(), and ignores alpha. Anything it can't read returns false instead of throwing.

Keeping the last good data

writeEdgeAppCache() stores the last value an app fetched successfully, and readEdgeAppCache() returns it when the next fetch fails:

import { readEdgeAppCache, writeEdgeAppCache } from '@screenly/edge-apps'

async function loadWeather() {
  try {
    const weather = await fetchWeather()
    writeEdgeAppCache('weather-app', 'forecast', weather)
    return weather
  } catch (error) {
    return readEdgeAppCache('weather-app', 'forecast')
  }
}

Values are stored as JSON in the player's localStorage under namespace:key. Nothing expires, and the cache can be empty after the player restarts, so read it only after a real failure. readEdgeAppCache() returns null when there is nothing usable, and writeEdgeAppCache() does nothing when storage is full or unavailable.

Error reporting to Sentry

setupSentry() and reportError() send an app's errors to your own Sentry project. Add a sentry_dsn setting to screenly.yml, set it to your project's DSN in Screenly, and call setupSentry() once when the app starts:

settings:
  sentry_dsn:
    type: secret
    title: Sentry Client Key
    optional: true
    is_global: true
import { setupSentry, reportError } from '@screenly/edge-apps'

setupSentry('menu-board')

try {
  await refreshPrices()
} catch (error) {
  reportError(error, { source: 'price-feed' })
}

Every event is tagged with edge_app (the name you pass) and the screen's hostname. The value of any setting whose key contains token, secret, password, or credential is replaced with [REDACTED] before an event leaves the screen, and an event that can't be checked isn't sent. scrubSensitiveData() is the same filter, for use as beforeSend if you initialize Sentry yourself.

With sentry_dsn empty, setupSentry() does nothing. It reports errors only, with no performance tracing.

Screenshot tests

captureScreenshot() opens a page at a given size, fixes the clock, mocks screenly.js, waits for the network to go quiet, and saves the image:

import { test } from '@playwright/test'
import {
  captureScreenshot,
  createMockScreenlyForScreenshots,
  RESOLUTIONS,
} from '@screenly/edge-apps/test/screenshots'

const { screenlyJsContent } = createMockScreenlyForScreenshots()

for (const { width, height } of RESOLUTIONS) {
  test(`screenshot ${width}x${height}`, async ({ browser }) => {
    await captureScreenshot(browser, {
      width,
      height,
      filenamePrefix: 'menu-board',
      screenlyJsContent,
    })
  })
}

Each run writes 1 file per resolution, such as screenshots/menu-board-1920x1080.png. Pass setupMocks to add routes of your own before the page loads. Keep test() in your own spec file, so Playwright reports failures against your file and line.

Versions

The library is numbered by year and month from 26.8.0, the release after 1.5.0. A range like ^1.5.0 in your package.json never matches a release again. Use ^26.8.0, which covers releases through the end of 2026.

Was this page helpful?

Thanks. Glad it helped.

Thanks. That tells us where to look next.

Type to search the documentation