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.

Shell
1bunx --bun --package @lst97-oss/last-cli last-cli create my-app
2npx @lst97-oss/last-cli create my-app

The 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

100%Drag to pan

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

last-payload-tanstack-start-template

The product needs editorial content, an admin, and Payload-managed users

Payload v4 + TanStack Start, /admin, /api/*, users-only auth, lexical content, committed migrations

last-tanstack-start-template

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. withPayloadRoot hands the document to Payload on admin routes, so data-theme, dir, and lang rules match. The admin bridge, import map, admin route, and not-found handling live in src/lib/payload/.
  • Committed migrations. The Postgres adapter runs with push: false and prodMigrations, so schema changes go through src/migrations/ and bun 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-start bump.
  • The kernel. The same src/server/ envelope, errors, request context, validation, database seam, and health slice described below.
  • A development-only /ui showcase. 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

100%Drag to pan

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 typed HttpError whose 5xx messages are hidden on the wire, withContext for 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/live never touches I/O, /api/ready probes the database, security headers are minimal and explicit, and shutdown drains Nitro on SIGINT/SIGTERM.
  • Boot proof. compose.yaml for local Postgres, bun run smoke for 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-nova components; Base UI toaster; theme provider; site not-found and error boundaries.
  • Toolchain. A single pinned Bun version, bun fmt for formatting, Biome for lint and import order, and bun check for 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

100%Drag to pan

The generated project is not running yet. The CLI prints the path, and the template README repeats it with the details:

Shell
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 dev

The 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

  1. 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.
  2. Pin the source commit. A generator that follows main cannot be reproduced. A 40-character hash can.
  3. Fail closed on the destination. Non-empty directories are refused before download, and only a directory the CLI created is removed after a failure.
  4. Separate setup from creation. git init and bun install are conveniences, not preconditions. A machine without them still gets a usable project and an actionable warning.
  5. Inject the process edges for tests. createProject accepts download and runCommand dependencies, so argument parsing, directory rules, rewrites, and cleanup are tested without prompting, cloning, or spawning anything.
  6. 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

lst97-oss/last-cli

The scaffolding CLI and its tests

lst97-oss/last-payload-tanstack-start-template

Payload v4 + TanStack Start starter

lst97-oss/last-tanstack-start-template

CMS-free TanStack Start starter and canonical kernel

The npm package is @lst97-oss/last-cli.