TweakTags Live demo Install Quick start GitHub

A self-hosted inline CMS for sites you already have.

TweakTags is a self-hosted inline CMS and visual content editor for Next.js, React, and plain HTML. Bolt it onto a website you already have, instead of rebuilding the site around a CMS. It lets you change the words and images on your site without leaving the page. Drop a data-tweaktags-* attribute on any element, and the people you trust can click in and edit it live. Everyone else just sees the finished content. It installs from npm and drops into any React or Next app, or a plain HTML site with a single script tag.

An open source inline CMS and visual content editor, created and maintained by Scarlett A. Scott (@scarlettiron).

Try it without installing anything. The live demo runs the real editor in your browser against an in memory backend. Sign in, change a heading and an image on the page, and save. No sign up, and nothing leaves the tab.

React, Next, or plain JS Postgres · MySQL · MariaDB · SQLite JWT auth with roles Rich text, plain text, media httpOnly cookies & CSRF
Get started →

How it works

When a page loads, TweakTags looks for your data-tweaktags- attributes and fills each one with whatever is saved in your database, so every visitor sees the latest content. Sign in, flip on edit mode, and those same spots open up for editing, right on the page, in a popup form, or from a full admin panel, whichever you like. The database work happens on the server through a small handler and a CLI, and none of it ever reaches the browser.

A tag can hold plain text, rich text, or media. For a media tag you just save an image url. TweakTags puts it on the element for you: on an <img> or <video> it sets the src, and on a container like a <div> it sets it as a cover background image. No upload pipeline to set up, just point at a url you already host.

Install

You need Node 16 or newer. If your app uses TypeScript you also need 4.5 or newer, since older versions cannot read the type declarations TweakTags ships. TypeScript is optional, everything works from plain JavaScript too.

One package asks for more: the optional @tweaktags/auth-aws-cognito needs Node 20 or newer, because the AWS SDK it depends on does. See Sign in with AWS Cognito for the full list.

Here is everything a Next.js app on Postgres needs to get going:

terminal
# the client bindings, server handler, and Postgres adapter
npm install @tweaktags/next
# the command line tool, for migrations and users
npm install -D @tweaktags/cli

Using a different database? Add its adapter package alongside the above:

terminal
npm install @tweaktags/db-mysql      # MySQL
npm install @tweaktags/db-mariadb    # MariaDB
npm install @tweaktags/db-sqlite     # SQLite
Plain React, not Next? Install @tweaktags/react and @tweaktags/server instead of @tweaktags/next, and mount the server handler on any Node route.

Quick start

1. Configure TweakTags

Create a tweaktags.config.ts in your project root. This one file is the source of truth for your database and auth. Pull your secrets from the environment so you never commit them.

tweaktags.config.ts
import { defineConfig } from '@tweaktags/next';

export default defineConfig({
  editInView: true,
  apiBasePath: '/api/tweaktags',
  database: {
    provider: 'postgres',
    connectionString: process.env.DATABASE_URL,
  },
  auth: {
    provider: 'jwt',
    jwtSecret: process.env.TWEAKTAGS_JWT_SECRET,
    cookieSecure: process.env.NODE_ENV === 'production',
  },
});

2. Add the API route

Mount the handler once. It reads your config and wires up the database for you.

app/api/tweaktags/route.ts
import { createTweakTagsRouteHandler } from '@tweaktags/next';
import tweaktagsConfig from '../../../tweaktags.config.js';

const { POST } = createTweakTagsRouteHandler(tweaktagsConfig);

export const runtime = 'nodejs';
export { POST };

3. Wrap your app

Add the provider and the edit bar to your root layout.

app/layout.tsx
import { TweakTagsProvider, TweakTagsEditBar } from '@tweaktags/next';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <TweakTagsProvider apiBasePath="/api/tweaktags">
          {children}
          <TweakTagsEditBar />
        </TweakTagsProvider>
      </body>
    </html>
  );
}

4. Set up the database and your first user

terminal
npx tweaktags migrate
npx tweaktags create-superuser --email you@example.com --password a-strong-password

5. Mark content as editable

Add a data-tweaktags-{tag} attribute to any element. That is it.

anywhere in your app
<h1 data-tweaktags-hero-title>Welcome to my site</h1>
<p data-tweaktags-hero-subtitle>The default text lives right here</p>

Sign in through the edit bar, turn on Edit page, and change the text in place. Reload to confirm it saved.

Three ways to edit

Everyone works differently, so TweakTags gives you three ways to edit. They all talk to the same server and the same data, so you can even mix them.

Mode How you turn it on Best for
Edit in place editInView: true (default) Editing text right where it appears on the page.
Popup form editInView: false Editing many tags at once, or hard to click spots.
Admin panel Render <TweakTagsAdminPanel /> on a route A traditional dashboard on its own page.

The edit bar & toolbar

The TweakTagsEditBar tucks into the corner of the page. Signed out, it is just a small login card. Once you sign in, it turns into a toolbar for saving, closing, managing tags, and pulling up help, and you can drag it out of your way by the grip. Edit a rich text tag and a little formatting toolbar pops up right above it.

Live preview · signed in bar
you@example.com
Live preview · rich text toolbar
Live preview · login card
Sign in to edit

The full page admin panel

If you would rather manage content away from the live site, drop <TweakTagsAdminPanel /> on its own route and you get a proper dashboard: a full page login, then tabs to view, create, and edit your tags. The view and edit lists each come with their own search and pagination, so big sites stay easy to get around.

Both lists show each tag's saved content, so you can read a tag before you open it for editing. Media shows the file itself, rich text renders the way it does on your page, and anything long collapses behind a Show more toggle.

app/admin/page.tsx
'use client';
import { TweakTagsProvider, TweakTagsAdminPanel } from '@tweaktags/next';

export default function AdminPage() {
  return (
    <TweakTagsProvider apiBasePath="/api/tweaktags" richText>
      <TweakTagsAdminPanel />
    </TweakTagsProvider>
  );
}
Live preview · admin panel
TweakTags admin you@example.com
hero-title plain Welcome to my site
hero-image media cdn.example.com/hero.png
Page 1 of 3

Theming and custom CSS

Out of the box the panel wears a dark look with a vibrant blue accent. Want your own colors? Pass a theme with any of the tokens below. Need to go further? Pass styles to restyle individual parts with your own CSS. Skip anything you do not care about and it keeps our default, so you only write the bits you actually want to change. There is also a className on the root if you would rather reach for a stylesheet.

app/admin/page.tsx
<TweakTagsAdminPanel
  // recolor everything from a few tokens
  theme={{ primary: '#16a34a', bg: '#0b0f0c' }}
  // override just the parts you want, the rest stay default
  styles={{
    card: { borderRadius: '1rem' },
    button: { textTransform: 'uppercase' },
  }}
  className="my-admin"
/>

Theme tokens: bg, surface, surfaceRaised, border, text, muted, primary, primaryHover, danger, font.

Style parts: page, loginCard, shell, topbar, nav, card, input, button, subtleButton, dangerButton, label, listRow, badge.

Managing users

Adding an editor or resetting a password used to mean opening a terminal on the server. Now it is a panel. Superusers get Users, everybody signed in gets Account, and both show up in two places: as tabs in the full page admin panel, and as panels in the floating edit bar.

What a superuser can do

  • See every user, with their email and role.
  • Add a user by email, starting password and role. The password must be at least 8 characters.
  • Change somebody's role in either direction. Promoting an editor and demoting another superuser are both allowed.
  • Reset somebody's password without knowing their old one.
  • Delete a user.

What everybody can do

Editors included, anybody signed in can change their own email and their own password from the Account panel. Both ask for the current password first, so somebody who walks up to an unlocked laptop cannot quietly take the account over.

Four rules the server will not let you break

These are enforced on the server, not just hidden in the interface, so they hold even for somebody calling the API directly.

RuleWhy
You cannot change your own role Otherwise the last superuser could demote themselves and leave nobody able to manage users, with no way back except editing the database by hand. Promote somebody else and let them change you.
You cannot delete yourself Same reason, more directly.
You cannot delete a superuser Change their role to editor first, then delete them. Deliberate friction: removing an administrator should be two decisions, not one.
Changing your own email or password needs your current password A borrowed session should not be enough to take the account over.

A demotion bites immediately, not when the access token expires. Every user management action reads the caller's role from the database rather than trusting the role written into their token, so a superuser demoted a second ago has already lost these powers.

Who gets signed out

What happenedWhose sessions end
Their role changed Theirs, everywhere. The role lives inside the access token.
A superuser reset their password Theirs, everywhere. A reset that left old sessions signed in would not be one.
They changed their own password Their other devices. The one they are using stays signed in.
They changed their own email Nobody. Only the name they sign in with changed.
They were deletedTheirs, everywhere.
Live preview · users panel
you@example.com superuser that is you
sam@example.com editor
alex@example.com superuser change to editor to delete

The command line has not gone anywhere. create-user, create-superuser and update-password still work, and are still how you make the very first superuser on a fresh install, before there is anybody to sign in as. TweakTags sends no email, so somebody whose password was reset for them finds out by being signed out. Tell them yourself.

Authentication

TweakTags has two ways to check a password and you pick one in the config. provider: 'jwt' keeps the users and the passwords itself. provider: 'aws-cognito' hands both to an AWS Cognito user pool. It is one line, and a required one, so the choice is made on purpose rather than defaulted into and migrated out of later.

provider: 'jwt' provider: 'aws-cognito'
What you get Built in, nothing to install. Passwords are bcrypt hashes in the __TweakTags__Users table and TweakTags issues its own access and refresh tokens. The refresh token rotates on every use, and a reused one is treated as theft: the whole session family is revoked. An AWS Cognito user pool holds the passwords and issues the tokens, and TweakTags verifies them against the pool's JWKS. Editors sign in with the account they already have, and losing that account takes the editor away with it.
What it costs A jwtSecret of 16 characters or more, and that is the setup. Access tokens are stateless, so a revocation only bites once the access token runs out, unless strictRevocation: true buys you the same second for one database read per request. @tweaktags/auth-aws-cognito, a pool, an app client that allows ADMIN_USER_PASSWORD_AUTH, and AWS credentials in the environment. One database read per request always, no refresh token rotation and so no stolen token detection, and nobody signs in until their row is linked.
When to pick it Unless the editors already have accounts somewhere, this one. Nothing else authenticates these people, so there is nothing to defer to. The editors already live in a pool you run, usually the same one your own application uses. One password each, and disabling it in one place closes the editor too.

Either way, roles live in the TweakTags users table and the rules under Managing users hold. The cookie, CSRF and tokenStorage settings are TweakTags' own as well, so they behave the same whoever checked the password.

The built-in provider

There is nothing to set up beyond the secret the tokens are signed with: migrate makes the users table and create-superuser puts the first person in it. Keep the secret out of the repository, and treat changing it as signing everybody out.

tweaktags.config.ts · auth options
auth: {
  provider: 'jwt',
  jwtSecret: process.env.TWEAKTAGS_JWT_SECRET, // 16 characters or more
  accessTtlSeconds: 60 * 15,        // 15 minutes
  refreshTtlSeconds: 60 * 60 * 24 * 7, // 7 days
  tokenStorage: 'cookie',       // or 'header' for a separate origin app
  csrfProtection: true,
  strictRevocation: true,       // reject access tokens the moment a session is revoked
}

What rotation and revocation actually do, step by step, is under Security.

Sign in with AWS Cognito

Install @tweaktags/auth-aws-cognito and TweakTags checks passwords against your AWS Cognito user pool instead of its own table.

Versions and requirements

This is the one package in TweakTags that needs Node 20 or newer, and the floor is the AWS SDK's rather than ours: @aws-sdk/client-cognito-identity-provider declares node >= 20.0.0. Everything else in TweakTags still runs on Node 16. The AWS pieces are ordinary dependencies of the package, so there is nothing extra to install.

ThingVersionNotes
Node 20 or newer For this package only. Node 16 is still the floor everywhere else in TweakTags
@aws-sdk/client-cognito-identity-provider 3.600.0 or newer A normal dependency of the package, installed for you
aws-jwt-verify 4.0.1 or newer Verifies the pool's tokens against its JWKS. Also installed for you
AWS Cognito itself Nothing to match A managed service, so the thing you pin is the SDK, not the service

There is no minimum AWS Cognito version, and that is the answer rather than something left out of the table. Cognito is a managed AWS service: AWS run one version of it for everybody, so what you pin is the SDK, not the service. The API these calls speak is the Cognito Identity Provider API, 2016-04-18, which is the version the SDK client targets.

What the pool has to allow

These have to be true of the pool before the config will work.

PrerequisiteWhy
The app client allows ADMIN_USER_PASSWORD_AUTH The password is checked on your server, not in the browser, so that is the flow TweakTags signs in with. It is off by default on a new app client.
AWS credentials come from the environment There are no credential fields in the config. The SDK finds them the usual way, from an instance role, a profile, or environment variables, the same choice the storage adapter makes. They need AdminInitiateAuth, AdminGetUser, AdminCreateUser, AdminSetUserPassword and AdminUserGlobalSignOut.
Nothing in the pool asks the user a question TweakTags answers no challenges. A sign-in that comes back with one, MFA or NEW_PASSWORD_REQUIRED, fails naming it, and that account cannot be used until it is cleared in Cognito. A pool with MFA enforced cannot be used at all.
A client secret only if the app client has one Set clientSecret and every call carries a SECRET_HASH built from it, which the adapter does for you. Leave it out otherwise.
tweaktags.config.ts
// npm install @tweaktags/auth-aws-cognito
export default defineConfig({
  database: { /* … */ },
  auth: {
    provider: 'aws-cognito',
    awsCognito: {
      region: 'us-east-1',
      userPoolId: 'us-east-1_AbCdEfGhI',
      clientId: process.env.COGNITO_CLIENT_ID,
      clientSecret: process.env.COGNITO_CLIENT_SECRET, // only if the client has one
    },
    // cookie, csrf and token storage are shared with the jwt provider
    cookieSecure: true,
  },
});

There is no jwtSecret here and the type will not let you add one: auth is a union, so a config names one provider and gets that provider's fields. Then tell the client which provider the server uses, with authProvider="aws-cognito" on <TweakTagsProvider> or TweakTags.init({ authProvider: 'aws-cognito' }). That is presentation only: it changes what the Add a user form offers, and the server enforces every rule whatever the client says.

Being in the pool is not access to the editor

A user can only sign in once their TweakTags row is linked to their AWS Cognito sub. The pool is usually shared with your own application, so everybody in it having an editor account would be a nasty surprise.

Somebody in the pool but not linked gets exactly the same "The email or password is incorrect" message as somebody typing the wrong password, so the login form cannot be used to work out who has access. It is also what a missed link looks like. Do the linking first and you will never see it.

On a fresh install the command line does both halves at once: create-superuser makes the account in the pool, or links the one already there when the address exists, and writes the link on the row it creates. When the TweakTags row already exists with no link, which is what moving an install from the jwt provider looks like, link it by hand with link-user. That is the bootstrap and the only way out of it, since nobody can sign in until they are linked and nobody can link somebody from the panel until they have signed in.

terminal
npx tweaktags migrate
npx tweaktags create-superuser --email you@example.com --password choose-a-strong-password

# or, for a user whose row already exists without a link:
npx tweaktags link-user --email you@example.com --external-id <their aws cognito sub>

The sub is the user's id in the pool, shown on their page in the AWS console, and --sub is accepted as a spelling of the same flag. The id is checked against the pool before it is written, so a typo fails there rather than at the next sign in. An unlinked row cannot have its password changed either, from the panel or the command line, so linking is the whole of the migration.

Create a new user, or link an existing account

With AWS Cognito configured, the Add a user form in the admin panel and the edit bar offers Create a new user, which takes an email, a password and a role, or Link an existing account, which takes an email and a sub and no password at all. A new account is made with the invitation suppressed and the password set as permanent, so there is no FORCE_CHANGE_PASSWORD challenge for the login form to answer.

An address already in the pool is linked, not rejected. Adding a user whose email already has a Cognito account links it and the panel says so with a notice, and their existing password is left untouched. On a shared pool it is their live login for another application, not yours to reset.

What changes when you switch

ThingUnder AWS Cognito
Roles Still in the TweakTags users table. AWS Cognito groups are never read or written, so every request costs one database read to resolve the role. Same cost as strictRevocation: true, and here it is not optional: a Cognito token carries only its own id.
Deleting a TweakTags user Leaves the Cognito account alone, and signs them out of nothing. Losing the CMS must not delete, or log out, the login your main application depends on. What shuts them out here is that verify needs a linked row and the row is gone.
Changing your own email Changes the TweakTags row only. The link is on the sub, which never changes.
Ending sessions A role change signs nobody out, and needs to sign nobody out: the role is in the TweakTags table and is read on every request, so a demotion bites at once. A password reset does sign them out, pool wide, because their password changed for every application the pool backs. That is all or nothing, so somebody changing their own password is signed out here too and has to sign back in.
Refresh tokens Cognito does not rotate them, so the stolen token reuse detection under Security has no equivalent here. It is a real trade.
accessTtlSeconds and refreshTtlSeconds Stop deciding how long a token lasts, which is the app client's setting now, but still decide how long the cookie holding it is kept. Match them to the app client or a cookie expires while its token is still good.
auth.strictRevocation Does nothing. Cognito already revokes at the source.
update-password Sets the password in Cognito for a linked user, rather than writing a hash into a column nobody reads.

No framework? No problem.

React is optional. @tweaktags/vanillajs gives you the same thing on any site: the draggable, mobile friendly edit bar, the popup editor, and the full admin panel, all in plain JavaScript. The backend is set up exactly the same way, only the browser side changes.

Drop in a script tag

The quickest way is a CDN script. It is one self contained file that also injects its own styles, so this is the whole browser side, no build step:

index.html
<script src="https://cdn.jsdelivr.net/npm/@tweaktags/vanillajs@1.0.0/dist/index.global.js"></script>
<script>
  TweakTags.init({ apiBasePath: '/api/tweaktags' });
</script>

unpkg works too, and its short form resolves to the script build for you: https://unpkg.com/@tweaktags/vanillajs@1.0.0. The CDN link is automatic once the package is on npm, nothing to set up. Pin a version like @1.0.0 instead of the latest so a release cannot change your site by surprise, and put the tag before </body> or add defer so the page is ready before it runs.

Or install it with npm

your bundle
// npm install @tweaktags/vanillajs
import { init } from '@tweaktags/vanillajs';

init({ apiBasePath: '/api/tweaktags', richText: true, theme: { primary: '#16a34a' } });

Mark content the same way, with data-tweaktags-{tag} attributes. Pass a theme to recolor everything, or a css string for finer control. For the full page dashboard on its own route, call TweakTags.mountAdmin('#admin', { apiBasePath: '/api/tweaktags' }) instead of init.

Databases

Supported versions

Every version below runs the full adapter test suite in continuous integration on each pull request, against a real server of that version. The tested list and the supported list are deliberately the same list.

DatabaseTested versionsNotes
Postgres 13, 14, 15, 16, 17, 18 13 is past upstream end of life; take 14 or newer for a new install
MySQL 8.0, 8.4 5.7 works, but reached end of life in October 2023
MariaDB 10.6, 10.11, 11.4, 11.8 10.5 works, but is past end of life
SQLite whatever better-sqlite3 bundles Not a version you choose

Postgres comes bundled with the server, so it needs nothing extra. For anything else, add the matching adapter package and point your config at it. Either way, migrate and create-superuser behave exactly the same, so you can switch databases without relearning anything.

Database Provider Extra package
Postgres postgres None, built in
MySQL mysql @tweaktags/db-mysql
MariaDB mariadb @tweaktags/db-mariadb
SQLite sqlite @tweaktags/db-sqlite

TweakTags owns a small set of tables that all start with __TweakTags__, so they never clash with your own tables.

CLI commands

Run these from the root of your app. Each one reads the same tweaktags.config.

Command What it does
tweaktags migrate Creates or updates the database tables.
tweaktags create-superuser --email E --password P Creates an admin who can create, retype, and delete tags.
tweaktags create-user --email E --password P Creates a regular editor who can only change existing content.
tweaktags update-password --email E --password P Sets a new password for an existing user.
tweaktags list-tags Lists every tag in the database.
tweaktags list-users Lists every user and their role.

Multi-tenant: one database, many sites

You can run several sites from a single TweakTags database. Each site is a tenant, and a tag belongs to a tenant, so a site only ever sees and edits its own tags. The best part: the tenant is decided by the server, never by the browser, so a visitor can never pick or fake one.

One config per site

If each site has its own deployment, give each one a tenant:

drystrip's tweaktags.config.ts
export default defineConfig({
  tenant: 'drystrip',
  database: { provider: 'postgres', connectionString: process.env.DATABASE_URL }, // shared
  auth: { provider: 'jwt', jwtSecret: process.env.TWEAKTAGS_JWT_SECRET },
});

Now hero-title on drystrip is a different tag from hero-title on another site, and an editor signed in on drystrip can only touch drystrip's tags.

One server, many domains

If a single deployment serves several domains, map the request to a tenant instead:

tweaktags.config.ts
export default defineConfig({
  resolveTenant: ({ host }) => (host?.endsWith('drystrip.com') ? 'drystrip' : 'default'),
  database: { provider: 'postgres', connectionString: process.env.DATABASE_URL },
  auth: { provider: 'jwt', jwtSecret: process.env.TWEAKTAGS_JWT_SECRET },
});
Good to know. Users are shared across tenants, so one superuser manages every site. Nothing changes on the client. Existing single-site installs keep working as the default tenant, and the migration backfills old rows for you.

Serverless & connection pools

On a normal Node server there is one process and one connection pool, and there is nothing to configure. Leave database.pool out and the driver's defaults apply.

Serverless is different. Every instance that wakes up builds its own pool, so the connection count multiplies by the number of warm instances: 50 instances × 10 connections = 500 connections. Postgres refuses new connections past max_connections, often 100 on a small managed instance, so the app starts failing to connect under exactly the traffic that scaled it up.

tweaktags.config.ts
export default defineConfig({
  database: {
    provider: 'postgres',
    connectionString: process.env.DATABASE_URL,
    pool: {
      max: 2,                        // each instance serves one request at a time
      idleTimeoutMillis: 10_000,     // let a sleeping instance let go
      connectionTimeoutMillis: 5_000, // fail fast instead of hanging
    },
  },
  // ...the rest of your config
});
SettingWhat it doesPostgresMySQL / MariaDB
maxMost connections one pool opensyesyes
minConnections kept open when idleyesignored, no minimum exists
idleTimeoutMillisHow long an idle connection is keptyesyes
connectionTimeoutMillisHow long to wait for a connectionyesyes

Every field is optional, and anything left out keeps the driver's own default rather than being overwritten. SQLite has one file handle and no pool, so it ignores this. Values are checked when the config is resolved, so a typo fails at startup rather than turning up later as a connection error. If your host offers a pooler such as PgBouncer, use it and keep max small as well.

Media uploads

A media tag holds an image url. By default you paste it in. Want to upload files instead? Point TweakTags at an S3 bucket, or any S3 compatible store, and editors get an Upload a file button next to the url box. Either works.

TweakTags never touches the file bytes: the server hands the browser a short lived presigned url, and the file goes straight from the browser to your bucket.

tweaktags.config.ts
// npm install @tweaktags/storage-s3
export default defineConfig({
  database: { /* … */ },
  auth: { /* … */ },
  storage: {
    provider: 's3',
    bucket: process.env.S3_BUCKET,
    region: process.env.S3_REGION,
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    endpoint: process.env.S3_ENDPOINT,        // R2 / Spaces / MinIO
    publicBaseUrl: process.env.S3_PUBLIC_BASE_URL, // optional cdn
  },
});

Then turn the button on in the client with mediaUpload (<TweakTagsProvider … mediaUpload>, or TweakTags.init({ … mediaUpload: true })). The one S3 adapter covers S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, and MinIO.

Two one-time bucket settings. Allow a PUT from your site's origin (CORS), since the browser uploads directly, and make the uploaded files publicly readable (a public prefix or a cdn with publicBaseUrl). Skip storage entirely and media still works, you just paste urls. Uploads are namespaced per tenant.

White label

TweakTags is white label out of the box. No TweakTags name appears anywhere your editors can see, including the admin panel, so an install looks like part of your own product. There is nothing to turn on.

If you would rather show the TweakTags name, set whiteLabel to false in two places: your config, and the client.

tweaktags.config.ts
export default defineConfig({
  whiteLabel: false,
  // ...the rest of your config
});
app/layout.tsx
<TweakTagsProvider apiBasePath="/api/tweaktags" whiteLabel={false}>

On a plain HTML site the same switch is TweakTags.init({ whiteLabel: false }).

Upgrading from 1.1.0 or earlier? White label used to be off by default. After upgrading, the TweakTags name disappears from your editors' UI unless you set whiteLabel: false.

Logging & failures

When something on the server goes wrong, TweakTags writes one line about it and hands the caller a trace id. You do not have to set anything up: with no logger in your config, entries go to the console your server already has.

server output
[tweaktags] ERROR request.failed trace=mtqj54hvf4av7g action=listTags status=500 code=internal_error durationMs=1
  connect ECONNREFUSED 127.0.0.1:5432
  {
    hint: 'Nothing answered at the database host and port. Check the database is running, and
           that the host and port in the config match it. From inside Docker, "localhost"
           means the container.',
    tenant: 'default',
    error: { name: 'Error', message: '...', code: 'ECONNREFUSED', stack: '...' }
  }

The trace id is the useful part. The browser gets the same id back, so when someone reports that saving failed, you search your logs for that one string and land on the line that explains it.

Hints

For the failures a self hosted install actually hits, the log says what usually causes them, in plain language:

  • A database asked for SSL that it does not support, or one that refuses a connection without SSL
  • Nothing listening at the host and port
  • A rejected username or password
  • A database that is not there, or a SQLite file that cannot be opened
  • TweakTags tables that were never migrated

Sending entries somewhere else

Set logger in your config to route entries into whatever you already run. It is called with one plain object per entry, so it fits any logging library.

tweaktags.config.ts
export default defineConfig({
  logger: (entry) => {
    // level, event, message, traceId, action, status, durationMs,
    // tenant, reason, hint, error { name, message, code, stack }
    myLogger[entry.level](entry.message, entry);
  },
  // ...the rest of your config
});

What the browser sees. In production the internal message stays on the server and the response carries only the trace id, so a database error never reaches a visitor. In development the message comes back as well, since whoever sees it is the person who can fix it.

Security

Auth is the part you do not want to get wrong, so TweakTags handles the fiddly bits for you, right out of the box:

  • Passwords are hashed with bcrypt before they are stored, when TweakTags is the one storing them. Which provider checks them is a choice you make under Authentication.
  • Short lived access tokens and longer refresh tokens, both with configurable expirations. When the refresh token expires, the user is signed out and logs back in.
  • Under the jwt provider, refresh tokens rotate on every use, and reuse of an old token revokes the whole session family, which stops stolen tokens.
  • Tokens are stored in secure httpOnly cookies by default, with a double submit CSRF check on every action that changes data. You can turn CSRF off with one config flag if it gets in your way.
  • Two roles: a superuser manages tags and users, an editor can only change content for tags that already exist. Saved content is checked for SQL injection and dangerous HTML.
  • User management reads the caller's role from the database on every call rather than trusting their token, so a demotion takes effect at once.

Deleting somebody, changing their role, or resetting their password all delete their refresh tokens, so they are signed out for good once their current access token runs out. Until then, up to accessTtlSeconds, the token they already hold still works for content edits. Set strictRevocation: true if you need a removal to take effect the same second.

The full list of auth settings, and the same list for an AWS Cognito pool, is under Authentication.