This site is the official documentation and single source of truth for the tether.io Documentation guild:
- Source code and content of the docs website
- Automation scripts for the integration between the codebase and the documentation
The site is a static export from a Next.js + Fumadocs app (output: 'export'). SEO behavior is implemented with workspace packages under @tether/docs-* (see below).
- Installation
- Monorepo packages (
packages/) - SEO and frontmatter
- Environment variables
- Open Graph images (Takumi, static hosting)
- Development
- Maintainers
- Build
- Repository layout
Prerequisites:
- Node.js >= 22.22.0
npm>= 10.9.2
Install dependencies:
npm installpostinstall runs fumadocs-mdx, which generates .source/ (gitignored). Run install after clone and after editing source.config.ts.
Copy the example file and fill in values (Next.js reads .env.local automatically):
cp env.example .env.localSee env.example for all variables. Required for production SEO:
NEXT_PUBLIC_DOCS_ORIGIN— public docs URL for canonical and social metadata (seo-config.ts); optional for local dev (defaults tohttp://localhost:3001)
Optional — Inkeep: set NEXT_PUBLIC_INKEEP_API_KEY only when you want Inkeep for search (replacing the Fumadocs default dialog) and the chat widget. If it is unset, the app uses Fumadocs' default search and hides Inkeep-specific UI (provider.tsx, layout.tsx, page-actions.tsx).
Note
npm run prebuild runs tsx outside Next.js, so DOCS_OG_* and SKIP_OG_BUILD are not read from .env.local unless you export them in your shell or set them in CI.
| Package | Role |
|---|---|
@tether/docs-seo-schema |
Zod tetherSeoFrontmatterSchema: required description; optional noIndex, ogImage, schemaType, docType, lastModified. Exports warnMissingSeoFrontmatterFields, DOCS_SEO_WARN_PREFIX. |
@tether/docs-seo-core |
getPageSeoState (canonical, ogImage override, PageSeoState with slugs / seoAuditFields), buildJsonLdGraph, inferJsonLdType. Root-relative ogImage paths are turned into absolute URLs without applying doc trailingSlash (so /asset.png does not become /asset.png/). |
@tether/docs-seo-next |
buildDocsMetadata, buildDocsSitemap, buildDocsRobots, DocsJsonLd; re-exports core/schema SEO helpers. |
@tether/docs-seo-og |
getPageImage (URL layout), Takumi prebuild (@tether/docs-seo-og/build), optional Route Handler (@tether/docs-seo-og/handler); re-exports warnMissingSeoFrontmatterFields. |
The main app must not import @tether/docs-seo-og/handler in pages that ship to the static bundle; Takumi is only used at prebuild time or in a server Route Handler.
@tetherto/docs-seo-* is published to GitHub Packages, not the public npm registry. Consumers install it like any normal npm dependency (@tetherto/docs-seo-next: ^1.1.0) once npm is pointed at the right registry and authenticated with a personal access token.
Add an .npmrc to the consumer repo so the @tetherto scope is fetched from GitHub Packages and the token is read from a GITHUB_TOKEN environment variable:
@tetherto:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Commit this file. It contains no secret — only the variable reference.
Create a classic PAT at https://github.com/settings/tokens/new with these scopes:
read:packages— required, to download@tetherto/*from GitHub Packagesrepo— required if any of the source repositories that publish those packages are private (otherwise GitHub returns403even with the right scope)
Important: set an expiration on the token. Tokens that never expire are rejected by the registry. Pick the shortest expiration that fits your workflow; you'll be regenerating it.
After creating the token, if the tetherto organization uses SSO, click "Configure SSO" next to the token in the tokens list and authorize it for the org. Without that step the registry returns 403 even with the right scopes.
A fine-grained personal access token also works, scoped to the tetherto resource owner with Packages read repository permission. Same expiration requirement applies.
Add GITHUB_TOKEN=ghp_yourTokenHere to a gitignored .env (or .env.local) file. Keep it readable only by you (chmod 600 .env). Never commit the token.
Quick sanity check:
set -a && . ./.env && set +a
npm whoami --registry=https://npm.pkg.github.com # should print your GitHub username
npm view @tetherto/docs-seo-schema version --registry=https://npm.pkg.github.comIf npm whoami prints your username but the second command returns HTTP 403, the token authenticates but lacks read:packages (and/or SSO authorization).
set -a && . ./.env && set +a
npm installCI sets GITHUB_TOKEN directly from secrets (e.g. the built-in secrets.GITHUB_TOKEN for org-internal workflows, or a PAT secret for cross-org reads) — no .env file involved.
- Add the packages you actually import (
@tetherto/docs-seo-next,@tetherto/docs-seo-og,@tetherto/docs-seo-schema); transitive@tetherto/*deps resolve automatically. - Sources are TypeScript and shipped as-is, so set
transpilePackagesin Next.js to include every@tetherto/docs-seo-*package you import. - Install peer dependencies (
fumadocs-core,next,react,zodfor schema, etc.) to versions compatible with each package'speerDependencies.
Extended fields are merged in source.config.ts via tetherSeoFrontmatterSchema. description is required (non-empty after trim) on every docs page for meta tags, Open Graph / Twitter, and JSON-LD. Other fields are optional:
noIndex(boolean): exclude from sitemap and setrobotsto noindex.ogImage(string): absolute URL or site-relative path override for Open Graph / Twitter images (relative paths are normalized as static assets, not doc routes—no stray trailing slash before the file extension).schemaType:TechArticle|APIReference|WebPagefor JSON-LD@type.docType:tutorial|how-to|reference|explanation|page|faq|getting-started(influences inferred JSON-LD whenschemaTypeis omitted).lastModified: string or date for sitemaplastmodand, when set, JSON-LDdatePublished/dateModifiedonWebPage,TechArticle, andAPIReferencegraphs.
Per-page metadata, sitemap, robots, and JSON-LD share the same logic through src/lib/seo-config.ts and @tether/docs-seo-next.
During next build / dev, getPageSeoState and buildDocsMetadata emit [@tether/docs-seo] console.warn lines for missing optional fields (ogImage, schemaType, docType, lastModified, and empty description if it bypasses MDX validation). Warnings are deduplicated per page per Node process. Two env knobs control them:
DOCS_SEO_SILENT=1— silence ALL warnings (including the required-descriptionwarning). Use sparingly.DOCS_SEO_QUIET_GENERATED=1— silence only the warnings for fields that have sensible auto-generated/inferred defaults (ogImage,schemaType,lastModified).descriptionanddocTypewarnings stay loud because neither has a useful default. Recommended when you opt into the Takumi OG prebuild + thefumadocs-mdxlastModifiedplugin.
The same helpers are re-exported from @tether/docs-seo-schema, @tether/docs-seo-core, @tether/docs-seo-next, and @tether/docs-seo-og (warnMissingSeoFrontmatterFields).
When using the SEO OG packages from a remote docs site:
- Clone with submodules (or after clone: git submodule update --init --recursive) so src/lib/docs-template is populated
- Run
npm installso workspaces link the @tetherto/docs-seo-* packages from the submodule - Run
npm run build(or whatever runs prebuild) sogenerate-takumi-og.tsxruns and fills public/og/docs/... for each page - CI / deploy, either:
- Checkout with submodules (same as local), or
- Keep using GITHUB_TOKEN / GitHub App flow from the README to install from GitHub Packages if the submodule isn't used in that environment
- Consider adding a fallback, such as /og-default.webp if the per-page OG files are missing
Full template with comments: env.example.
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_DOCS_ORIGIN |
Yes (production) | Site origin for metadataBase, canonicals, and absolute og:image / Twitter URLs. Defaults to http://localhost:3001 when unset. |
NEXT_PUBLIC_INKEEP_API_KEY |
No | Inkeep CXKit API key. If set, replaces Fumadocs default search with Inkeep and enables the Inkeep chat widget; if unset, default Fumadocs search is used. |
NEXT_PUBLIC_DOCS_PUBLISHER_LOGO_URL |
No | HTTPS logo for JSON-LD publisher / Organization. |
SKIP_OG_BUILD |
No | Set to 1 to use static OG fallback instead of per-page public/og/docs/** URLs in metadata. |
DOCS_OG_SITE_LABEL |
No | Takumi site label during OG prebuild (default Tether). |
DOCS_OG_CONCURRENCY |
No | Parallelism for OG prebuild (default 3). |
DOCS_SEO_SILENT |
No | Set to 1 to turn off ALL [@tether/docs-seo] console warnings (including required-description checks). |
DOCS_SEO_QUIET_GENERATED |
No | Set to 1 to silence only the warnings for fields with auto-generated / inferred defaults (ogImage, schemaType, lastModified). Keeps description and docType warnings live. |
Because static export cannot use dynamic OG Route Handlers, images are generated before next build and written under public/og/docs/.../image.webp, matching the URLs returned by getPageImage().
npm run buildandnpm run build:staticautomatically runprebuild, which executestsx scripts/generate-takumi-og.mts- Run the generator alone:
npm run build:og - Replace
public/og-default.pngwith a proper 1200×630 asset if you rely on theSKIP_OG_BUILDfallback
Git note: this template gitignores public/og/docs/ (see .gitignore). CI and local npm run build must run prebuild so those WebP files exist before static export. To vendor generated images instead: stop ignoring that directory and commit the files.
Vale is available for local documentation linting. Run vale sync before linting so Vale downloads the configured package styles into the gitignored styles/ package directories.
Install the Vale CLI first (see the Vale install guide for other platforms):
# macOS (Homebrew)
brew install valeUse the project vocabulary to check custom spelling. For example, Tether should pass, while Tehtr should fail.
Run Vale against a single file:
vale sync
vale README.mdRun Vale against a single folder:
vale sync
vale content/docsRun Vale against the entire repo:
vale sync
vale .Vale can be added to CI as an advisory check, but do not configure it to fail CI. The current rule set has too many false positives for a hard gate.
Check broken links:
npm run check-linksDev server (port 3001):
npm run devFor local dev without generating OG files, you can use:
SKIP_OG_BUILD=1 npm run devThis repository provides an opinionated Vale configuration. To use in downstream documentation repositories either:
-
Lint against the shared config — call this repository's reusable workflow with
central_config: true. It checks out this template beside the downstream repository and lints against the central.vale.iniandstyles/, advisory by default (fail_on_error: false). -
Lint against your own config, on the shared rules — call the reusable workflow with
config_pathpointing at a.vale.iniin the downstream repository. The template is still checked out beside it at.tether-vale-source, so that file can setStylesPath = .tether-vale-source/stylesand choose which Tether rules apply and at what level, without copying any rule. Use this when a repository needs a different alert floor from the central one. A minimal override file:# Rules come from docs-template, checked out beside this repo by the shared workflow. StylesPath = .tether-vale-source/styles Vocab = Tether-common # Alert floor: only rules at or above this level surface. MinAlertLevel = error [*.md] # Which shared styles to load. Omit Google, proselint and write-good to skip # their warnings entirely. BasedOnStyles = Tether, Vale # Per-rule levels, raised or lowered as this repository needs. Tether.CaseSensitive-Substitution = error Tether.Headings = error
-
Lint against a fully custom config — keep a local
.vale.iniand styles in the downstream repository and run Vale in that repository's own CI (the Vale linting setup above covers the CLI).
The reusable workflow's self-lint mode (central_config: false and no config_path) is reserved for this template: it uses the local .vale.ini and always fails the job on errors, so it isn't a configuration path for downstream repositories.
Add a workflow like this to the downstream repository. The example pins to a reviewed commit SHA for a reproducible gate; see the tradeoff with tracking a branch below.
name: Vale documentation lint
on:
pull_request:
paths:
- "**/*.md"
- "**/*.mdx"
jobs:
vale:
# Keep the workflow ref AND docs_template_ref on the same ref (SHA or branch).
uses: tetherto/docs-template/.github/workflows/vale-docs.yml@<reviewed-commit-sha>
permissions:
contents: read
pull-requests: read
with:
central_config: true # lint against this template's central .vale.ini
# Or, instead of central_config, your own file on the shared rules:
# config_path: .vale.ini
docs_template_ref: <reviewed-commit-sha>
# Optional — exclude generated, vendored, cached, or build-output files
# (comma-separated globs):
files_ignore: "path/to/generated/**,**/vendor/**"
# Optional — defaults to "*.md,**/*.md,*.mdx,**/*.mdx" (comma-separated):
# filepaths: "*.md,**/*.md,*.mdx,**/*.mdx"
fail_on_error: false
# Optional — set false to keep findings out of the diff view and show
# them only in the job summary and the check's annotation list:
# inline_annotations: trueChoose how closely the downstream gate tracks this template:
- Pin to a reviewed commit SHA — reproducible: the workflow and the Vale config it loads never change until you bump the ref deliberately. Use the same SHA for both the
uses:ref anddocs_template_refso they always move together. - Track a branch like
@main— always current: the downstream repository picks up config changes automatically, at the cost of a gate that can shift under you between runs (a style tweak here can turn a green check red without any change on your side). Setdocs_template_refto the same branch.
The workflow runs a pinned Vale release directly and prints each alert as a GitHub annotation through .github/vale/github-annotations.tmpl. The job is the only check that reports: findings appear inline on the pull request diff and the job summary lists every one of them. GitHub shows at most ten inline annotations per level per step, so the summary is the complete view. No checks: write permission is needed.
Then choose how findings gate CI with fail_on_error:
false(default) — advisory: Vale reports findings but the job stays green. Recommended, because Vale has too many false positives for a hard CI gate, and a red check that reviewers learn to ignore weakens every check that is meant to block.true— hard gate: Vale errors fail the job. Set this only after the downstream repository has cleaned up or accepted the rule set.
Either way a Vale run that cannot complete (a broken config or rule, exit code 2) fails the job, because then nothing was checked.
Synced package styles such as Google, proselint, and write-good are generated by vale sync during the workflow run. Do not commit those generated package directories to downstream repositories.
Set NEXT_PUBLIC_DOCS_ORIGIN for production; add NEXT_PUBLIC_INKEEP_API_KEY only if you use Inkeep instead of default Fumadocs search (see env.example).
Static export:
npm run buildor:
npm run build:staticNext.js writes the static site to the out/ directory (not dist/).
Serve the export locally after npm run build:
npm run serveThis serves the out/ directory (Next.js static export output).
src: Next.js app and UIcontent/docs: MDX documentation contentpackages: workspace packages (@tether/docs-seo-*)public: static assets;public/og/docs/**holds prebuilt OG WebP files afterprebuildexamples: runnable DOCS code samples for snippets and toolingscripts: automation (includingscripts/generate-takumi-og.mts)env.example: environment variable template (SEO required for prod; Inkeep optional)REVIEW-CHECKLIST.md: optional manual QA checklist for SEO / static export (stage it if you want it in the repo).source/(gitignored, not in git): Fumadocs MDX output; created bynpm install/npm run postinstall. Regenerate after changingsource.config.ts
Note
Repository structure may evolve as automation and content organization mature.