The problem
Every new project starts with the same decisions. Where does server code live? What shape does an error have on the wire? How is the database reached, and who owns the connection pool? Which health endpoint does the platform probe? How does CI prove the application actually boots? How is the build wired so a route handler is not accidentally bundled into the browser?
Answering those questions once per project is how a portfolio turns into a set of snowflakes. The answer is not a framework: it is a starter that already made the decisions, plus a generator that can hand it to you without a manual copy-paste.
What last-cli is
last-cli is a small scaffolding CLI for my own projects. It does one thing: copy a maintained LAST starter into a new directory, rename it, and leave a bootable project. It is published on npm as @lst97-oss/last-cli, currently at 0.2.2, and it is deliberately personal-use — it is the tool I reach for, not a general-purpose generator with a plugin ecosystem.
1bunx --bun --package @lst97-oss/last-cli last-cli create my-app
2npx @lst97-oss/last-cli create my-appThe Bun form is the primary path. The npm form works with Node.js 22 or newer. The command creates my-app, rewrites its package name and README title, copies .env.example to .env, and optionally initialises Git and installs dependencies. It does not run migrations, start a database, or touch an existing project.
How a project is created
The command parses a tiny argument grammar: create, an optional project name, and --yes. There are no subcommands beyond create and no options beyond the flag. A non-interactive invocation without --yes is refused with instructions rather than left waiting on a prompt that no terminal can answer.
The destination must not already exist, or must be an empty directory. A non-empty directory is refused before anything is downloaded, so the CLI cannot overwrite work. The download uses giget against a GitHub repository at a pinned commit. When a download or the post-download rewrite fails, a directory the CLI created is removed; a directory that already existed is left alone, because deleting it would delete the user's files.
Naming is intentionally simple: the project name must be a lowercase slug of letters, numbers, and hyphens. The package manifest and README H1 are rewritten from that name, and .env is copied from the example. Setup commands are then attempted. A missing git or bun, or a failed install, becomes a warning; the project is kept and the warning tells the user which command to run in the new directory. The CLI reports success with the next commands, not with a hidden side effect.
Two templates, two use cases
Template | Use it when | What it gives you |
|---|---|---|
The product needs editorial content, an admin, and Payload-managed users | Payload v4 + TanStack Start, | |
The product is a business application or an API and needs no CMS | TanStack Start, the canonical backend kernel, live and ready probes, the full shadcn registry |
The two are not competing starters. They share one backend kernel and one frontend toolchain, and differ only in whether Payload sits in front of the application. That is the whole selection criterion the CLI shows: "Payload + TanStack Start" or "Blank TanStack Start".
The Payload + TanStack Start template
This is the CMS-backed starter. It boots standalone: a placeholder /, the Payload admin at /admin, and Payload's REST surface at /api/*. The collection surface is intentionally tiny — users with auth, and a plain lexical editor — because a starter that ships ten half-used collections is harder to delete than to extend.
The parts that are not obvious from a file tree:
- Admin shell.
withPayloadRoothands the document to Payload on admin routes, sodata-theme,dir, andlangrules match. The admin bridge, import map, admin route, and not-found handling live insrc/lib/payload/. - Committed migrations. The Postgres adapter runs with
push: falseandprodMigrations, so schema changes go throughsrc/migrations/andbun run db:migrate, never through dev auto-push. - Vendored RSC wiring. The Payload TanStack plugins in
vendor/are the RSC-independent subset. They are copied rather than wrapped, and re-copied after a@payloadcms/tanstack-startbump. - The kernel. The same
src/server/envelope, errors, request context, validation, database seam, and health slice described below. - A development-only
/uishowcase. It exercises the installed shadcn components; production requests return 404.
The template is explicit about what it leaves out: real auth beyond Payload users, more collections, i18n, pagination UI, rate limiting, OpenAPI, email, storage, RAG, and chat. Those are application decisions, not starter decisions.
The blank TanStack Start template
This is the CMS-free starter, and it owns the canonical business kernel. It boots a placeholder / and two health endpoints, /api/live and /api/ready, on top of the same envelope, validation, Bun-native Postgres client, structured logger, graceful shutdown, Compose file, smoke check, and CI pipeline as its Payload sibling.
It also ships the full shadcn registry in src/components/ui/, the shared Tailwind v4 semantic tokens, and the development-only /ui showcase. There is no Payload, no /admin, no collection layer, and no real auth: withAuth is a 501 stub where an application plugs in its own session verification.
That makes it the right starting point for a service whose content is not editorial — a business application, an internal tool, or an API — and the wrong starting point if the first requirement is "editors need a login and a content form".
One kernel, mirrored rather than forked
The blank template is the source of truth for src/server/. The Payload template mirrors it file for file, with three deliberate CMS-only extras: env.ts gains the Payload secrets and public origin, security/payload-server-url.ts adds the production loopback guard, and payload-shutdown.ts also drains cached Payload instances.
The mirror is maintained by a sync script driven by scripts/kernel-sync.json. CI runs it weekly and opens a pull request when the Payload copy has drifted. The rule that makes this work is directional: fix the kernel in the blank template first, then mirror it. Editing the Payload copy directly creates drift that the next sync will silently propose to undo.
Templates are pinned, not floated
The CLI does not download a branch, a tag, or the latest release. Both template sources are 40-character commit hashes, and a test asserts that shape. That is the difference between a generator and a moving target: upgrading last-cli is the only way a generated project picks up a new template revision, and the changelog records which template release each CLI version pins.
Version 0.2.2 pins last-payload-tanstack-start-template v0.4.1 and last-tanstack-start-template v0.1.2. A template fix that has not been reviewed for a CLI release does not reach new projects by accident.
What every generated project inherits
The two starters share more than a directory layout:
- HTTP contract. A fixed
{ data }/{ error: { code, message } }envelope, a typedHttpErrorwhose 5xx messages are hidden on the wire,withContextfor request IDs and never-throwing error mapping, and zod helpers for JSON bodies, query strings, and pagination. - Data access. One shared Bun-native Postgres client with a Drizzle seam. No per-module pool, no second connection-string parser, no migration runner hidden inside a module.
- Operations.
/api/livenever touches I/O,/api/readyprobes the database, security headers are minimal and explicit, and shutdown drains Nitro onSIGINT/SIGTERM. - Boot proof.
compose.yamlfor local Postgres,bun run smokefor the running server, and CI that runs format, lint, typecheck, migrations, tests, a node-server build, and the smoke check against the live process. - Frontend. TanStack Query, Form, and Store already wired; Tailwind v4 semantic tokens; shadcn
base-novacomponents; Base UI toaster; theme provider; site not-found and error boundaries. - Toolchain. A single pinned Bun version,
bun fmtfor formatting, Biome for lint and import order, andbun checkfor types.
None of this is novel. The value is that it is decided once and then copied, so the first commit of a new project is about the product rather than about plumbing.
The first five minutes
The generated project is not running yet. The CLI prints the path, and the template README repeats it with the details:
1cd my-app
2# edit .env
3bun run db:up
4bun run db:migrate
5bun run generate:types # Payload template only
6bun run generate:importmap # Payload template only
7bun run generate-routes
8bun run devThe blank template skips the two Payload generation commands and goes from db:up to dev. The Payload template additionally needs bunx payload create-first-user before /admin has an account to sign in with. Docker is optional: any Postgres matching .env works.
Deliberate exclusions
The CLI has one command and no update, eject, or registry mechanism. A generated project belongs to its owner; there is no hidden link back to the generator.
The templates are equally deliberate. The Payload starter excludes real auth beyond its users collection, extra collections, i18n, pagination UI, rate limiting, OpenAPI, email, storage, RAG, and chat. The blank starter excludes Payload entirely and leaves auth as a 501 stub. Both choices push application decisions into the application, where they can be made with actual requirements instead of inherited guesses.
Engineering decisions
- Copy the template instead of rendering it. The repository you can read is exactly what the CLI downloads. There is no second template language, no token table, and no generated file that does not exist upstream.
- Pin the source commit. A generator that follows
maincannot be reproduced. A 40-character hash can. - Fail closed on the destination. Non-empty directories are refused before download, and only a directory the CLI created is removed after a failure.
- Separate setup from creation.
git initandbun installare conveniences, not preconditions. A machine without them still gets a usable project and an actionable warning. - Inject the process edges for tests.
createProjectacceptsdownloadandrunCommanddependencies, so argument parsing, directory rules, rewrites, and cleanup are tested without prompting, cloning, or spawning anything. - Keep the surface small. The CLI has no configuration file because it has nothing to configure. The template is the configuration.
Current status
last-cli is personal-use tooling, currently at 0.2.2 under the MIT license. Both templates are public and can be cloned directly without the CLI; the CLI simply removes the copy, rename, and setup steps.
Repository | Role |
|---|---|
The scaffolding CLI and its tests | |
Payload v4 + TanStack Start starter | |
CMS-free TanStack Start starter and canonical kernel |
The npm package is @lst97-oss/last-cli.

● COMMENTS