Self-hosted service
twikoojs/twikoo avatar
twikoojs/twikoo

twikoo: a comment system for static sites, where the compose file and the README disagree on the throttle

A simple, safe, free comment system for static websites.

2,297 stars536 forksTypeScriptMIT

At a glance

What is it?
Nine languages, seven deployment platforms, and a 2.0 line that moved a single-file Cloudflare port into the monorepo. Two documented numbers do not survive contact with the files.
Who is it for?
Twikoo is a serious piece of work for a static-site comment system, and the 2.0 line is where the interesting engineering is.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 11, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the project is, in the project's own framing

The README opens with a three-word claim in two languages: a simple, safe, free comment system for static websites. That is an accurate summary of a fairly wide feature surface, and the repository backs it up.

The scope is worth naming before anything else, because a comment system is not one thing. There is a client that renders on your static pages, a backend that stores and serves comments, a moderation panel, an anti-spam pipeline with several pluggable providers, image hosting integration, and a notification fan-out to a dozen channels. The README organises these under headings for simplicity, ease of use, security, immediacy, customisation, and admin.

The deployment story is the widest part. The README lists free hosting options including Tencent CloudBase, Vercel, Netlify, Hugging Face, Railway, Cloudflare Workers, AWS Lambda, and EdgeOne Makers, plus a private server as the comment backend, with a one-click Vercel deploy and Docker for self-hosting. Eight named platforms plus your own server is a lot of surface area for one comment system, and it explains the monorepo structure: each platform needs its own adapter because each one has a different function signature, a different storage story, and a different timeout budget.

That breadth is also the honest downside. Every platform you add is a promise to keep an adapter working as that platform changes, and the 2.0 release notes are essentially a catalogue of those promises being kept or broken.

The feature list that costs you nothing to read

The easy-to-use section is where the project's personality shows, and a few entries are genuinely thoughtful.

Comments support replies, likes, and dislikes. No adaptation is needed to pair with either a light or a dark theme. There is an API for batch-fetching comment counts and recent comments. Pasting an image into the comment box works, as does inserting one, and both can be disabled. There is a Ctrl+Enter shortcut for submitting, and drafts in the comment box are saved live so a refresh does not lose them. Katex formula support and syntax highlighting by language are both there. Image hosting covers a CloudBase gallery service, several third-party services, PicList, EasyImage, Chevereto, and S3-compatible storage including Cloudflare R2 and MinIO. Emoji insertion follows the OwO data format, which means an existing OwO emoji list can be reused.

Two entries are worth singling out because they are the kind of detail that only matters after you have shipped. A numeric QQ email address becomes the commenter's QQ avatar automatically, and typing a QQ number into the nickname field fills in both the nickname and the email. These are identity shortcuts for a specific audience rather than general features, and they are the sort of thing that is easy to add once you notice it and impossible to guess from the outside.

The admin side is an embedded panel with password login, where you can view, hide, and delete comments and change configuration. There is also a hidden-entry mode where a passphrase reveals the panel. And there is import from Valine, Artalk, and Disqus, which is the feature that decides whether you can move an existing blog without losing its comment history.

The throttle limit, documented twice, with different meanings

This is the clearest documentation conflict in the repository, and it sits between the README and the compose file.

The README's security section lists support for limiting how many comments each IP address may publish in a ten-minute window. That is a publishing rate limit, expressed as comments per ten minutes per IP.

The compose file sets an environment variable for the self-hosted image, and its own comment describes that variable as the maximum number of requests per minute for a single IP.

yaml
image: imaegoo/twikoo
TWIKOO_THROTTLE: "250"

Those are different controls in three ways, and the difference matters. The window differs, ten minutes versus one. The unit differs, comments versus requests, and a request includes reading comments, which a page load does many of. And the value differs, 250 requests per minute is a much looser ceiling than 250 comments per ten minutes if you are measuring against abusive behaviour.

Both facts come from the project and both are current in their own document. There is no reason to think either is wrong, and the most likely explanation is that the variable covers request throttling generally while the README describes a separate comment-specific limit, or that one of the two descriptions was written for a different version of the configuration. The README is also the Chinese original with an English translation alongside it, and translation drift is a plausible third explanation.

Rather than pick one, the actionable move is concrete. Read the server package's configuration handling for that variable and see what it counts and over what window, then set it deliberately rather than inheriting the example value. If you are deploying to a platform with its own request budget, you also need to know which of the two limits binds first, because a platform timeout and an application throttle are different failure modes.

Nine languages, one of them listed twice

The internationalisation section states that the project supports nine languages and then lists them.

The list runs simplified Chinese, traditional Chinese, English, Japanese, Korean, Vietnamese, Bahasa Indonesia, and then two Uzbek entries. One is written in Latin script with an okina character in the name, and the other in Cyrillic. Both are Uzbek, one transliterated and one in its native Cyrillic form.

That makes eight distinct languages with nine localisation entries. Both counts are useful, and neither is what the README says it is. A locale list is not a language list when two locales serve one language, and a project that ships both scripts for Uzbek has done something deliberate rather than accidental, since it means a reader can choose the script they are comfortable with.

The practical consequences are small but real. If you are translating or reviewing the i18n files, you should expect two Uzbek files that must be kept in sync rather than one, and a translation pull request that touches Uzbek needs to decide whether it updates one script or both. If you are reading a coverage statistic that treats locales as languages, the project will look one language more capable than it is. The locales live in the client package's src/i18n/locales directory, and the README invites translation pull requests there, which is the first place to look if you want to contribute.

What the 2.0 line actually changed, in three releases

The 2.0 releases are dense and worth reading in order, because they describe a restructuring rather than a feature parade.

Version 2.0.9, published 2026-09-24, adds a Cloudflare Workers adapter as a new package inside the monorepo, storing data in D1 and using the platform's waitUntil to move side effects out of the request budget. It also adds a spam filter provider, and it states the priority order explicitly: a Tencent content-safety service first, then Akismet, then the new provider, then a language model service, with only the first configured service used. Stating that order is good practice, since it removes the guesswork about what happens when you configure two.

The same release fixes the EdgeOne Makers adapter, which the notes say was previously impossible to deploy. The platform expects an exported request function taking a context and returning a web standard Response, while the adapter exported a different handler shape and returned a plain object, and the package did not have the directory the platform looks in, so the platform registered zero routes. The fix rebuilds the entry layer to the platform's contract and adds back a Go SMTP bridge that existed in 1.x and was missing from 2.0. Deployment also became a one-click package of about five kilobytes with the implementation pulled from npm, and the notes flag that the platform's default domain is a three-hour preview that must be replaced with a custom domain for production.

Version 2.0.10, published the same week, fixes a cold-start failure on CloudBase. The adapter was lazily importing a heavyweight SDK on the request path, and the free tier of that platform has a fixed three-second execution timeout that cannot be raised. The numbers in the notes are stark: a cold call went from about 2477 milliseconds to 222, and a cold comment submission that had been timing out at 3000 milliseconds without storing the comment now completes at about 1078. The fix moves the heavy imports to the top level of the module, which is what makes it work, because there is no time left to load them during the request. The cost is stated too: initialisation went from roughly 0.6 seconds to 3.6 and resident memory rose by about 36 megabytes. The notes are careful about verification, saying the measurements come from calling the function directly in the platform console and that the real path through the browser and the HTTP gateway has not yet been retested.

Version 2.0.11, published 2026-09-28, fixes a bug where a notification package's CommonJS entry pointed at a file the build never produced. The ESM path worked, which is why it went unnoticed, and the release notes state plainly that the fault was present in every published 2.0 release from the first beta through 2.0.10. It also raises a publishing pipeline timeout from ten minutes to an hour and changes how it measures that timeout, explaining that counting accumulated sleep seconds undercounted the real wait because each round also ran several registry queries. That is a small change and it is the kind of thing worth noting: the project's own release tooling was failing in a way that silently skipped three downstream jobs.

The Docker image does not build this repository

This is the detail that matters most if you plan to fork, and the Dockerfile is unusually explicit about it.

The image does not compile the source in this repository. It installs a published package from the npm registry at the version the build passes in, defaulting to latest.

dockerfile
ARG NODE_VERSION=26
EXPOSE 8080
CMD ["/app/node_modules/.bin/tkserver"]

The Dockerfile's own comments explain the reasoning: the version is passed as a build argument by the publishing workflow, the docker job runs after npm verification so the image content is exactly what that version published, and with no argument it installs latest. The node version argument defaults to 26, matching the monorepo's own engine requirement.

For a user this is a good design, because the image is guaranteed to contain a released artifact rather than an arbitrary checkout. For anyone who forks the project expecting their changes to appear in a self-hosted image, it is a trap, and a subtle one, because the build succeeds and produces a working image that does not contain your code. If you fork, you either patch the Dockerfile to build from source, or you publish your own package and point the version argument at it.

Two more things from the container files are worth knowing. The compose file mounts a named volume at the data directory and sets a timezone, which affects both log timestamps and the last-updated text shown to readers. And it documents that if you supply an external MongoDB connection string, the data directory is then only used for the captcha cache, so a deployment that moves to an external database does not lose everything, it just changes what the volume is for.

The local development story is the ordinary pnpm monorepo one, with the README listing the commands directly.

sh
pnpm install
pnpm demo
pnpm lint
pnpm typecheck
pnpm test
pnpm build
+

Who runs it, and what to check before you commit to a platform

The environment example file is the most complete inventory of what the server can talk to, and reading it tells you the shape of the whole system.

It separates test variables from production ones, and its header explains the precedence: CI secrets first, then a local env file, then in-code defaults, with an empty value meaning not configured so a test case skips. That is a sensible convention and it is the reason the anti-spam list is easy to reason about, because each provider has a key variable and nothing runs until it is filled in.

The providers listed are an external MongoDB connection string with an optional database name, SMTP settings for email, a push notification channel and token, an Akismet key paired with the site URL that key is registered against, Tencent content-safety credentials, an AI spam filter with an endpoint, a key, and an optional model name, and image hosting credentials. The AI entry describes its endpoint as OpenAI-compatible, which is the important compatibility fact, because anything speaking that shape can be pointed at it.

So before choosing a deployment target, the question to answer is which of these you will actually configure, because every one you configure is a service you now own keys for. A minimal deployment that uses manual moderation and no notifications needs none of them, which is a reasonable starting point and a good way to find out whether you need the rest.

One loose end to be aware of when reading around the project: the licence badge in the README still points at an older organisation name for this repository, and the Docker image name uses that same older name as its namespace. Both work, because the old path redirects and the image is published under the old name, but if you are writing automation that resolves either from the current repository name, resolve them by testing rather than by assuming.

Editorial conclusion

Twikoo is a serious piece of work for a static-site comment system, and the 2.0 line is where the interesting engineering is. Moving the Cloudflare adapter from a separate single-file repository into the monorepo, folding the business logic into a shared common package, and rewriting the EdgeOne Makers entry point to satisfy a platform contract that exports a different function shape are the kind of changes that show a project being maintained rather than merely version-bumped. Two documentation conflicts are worth resolving before you deploy. The throttle limit is documented twice with different semantics: the README describes a per-IP cap on comments published in a ten-minute window, while the compose file sets an environment variable the project documents as a per-IP maximum number of requests per minute, which also covers reads. And the internationalisation section claims nine languages while listing two scripts of the same Uzbek language, so read that as eight distinct languages plus a transliteration pair. Neither gap is dangerous, and both cost you an afternoon of guessing if you are relying on either behaviour.

Frequently asked questions

What is Twikoo used for?

It is a comment system for static websites. You run a small backend somewhere and render a client on your pages, and you get replies, likes and dislikes, an embedded admin panel for moderation, image and emoji insertion, formula rendering, syntax highlighting, import from other comment systems, and notifications to several channels. It is MIT licensed.

Where can I deploy Twikoo?

The README lists Tencent CloudBase, Vercel, Netlify, Hugging Face, Railway, Cloudflare Workers, AWS Lambda, and EdgeOne Makers, plus a private server as the backend. Docker is the self-hosting path, with an image published under an older organisation name and a compose file that mounts a volume for the data directory.

How do I run Twikoo locally to work on it?

It is a pnpm monorepo on Node 26. Install dependencies, then run the demo command, which starts the client in watch mode, the server, and a demo page on port 9820. There are also lint, typecheck, test, and build scripts, and the build output lands in the client package's dist directory.

What spam filter does Twikoo use?

Several, tried in a fixed order. A Tencent content-safety service first, then Akismet, then a structured decision model service, then a language model service reached through an OpenAI-compatible endpoint. Only the first configured service is used, and each has its own key variable in the environment example.

Can I move my existing comments into Twikoo?

Yes. The README lists import from Valine, Artalk, and Disqus. The embedded admin panel is where you review, hide, and delete comments, and you can hide the panel entry behind a passphrase if you would rather not advertise it.

How many languages does the Twikoo client support?

The README says nine and lists nine entries, but two of them are Uzbek in different scripts, one Latin and one Cyrillic. That makes eight distinct languages across nine locale files. Translation pull requests are invited and go into the locale directory in the client package.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. twikoojs/twikoo on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/twikoojs-twikoo.svg)](https://hysenlabs.com/projects/twikoojs-twikoo)