App Setup Commands
Contents
- Phase 2: Create Next.js app
- Phase 2.1: Upgrade to TypeScript 7
- Phase 2.2: Turn on Instant Navigations
- Phase 3: Install Blode UI components and icons
- Phase 4: Install Agentation
- Phase 4.1: Add Google Analytics (optional)
- Phase 5: Install Ultracite
- Phase 5.1: Enable ultracite/oxlint/shadcn
- Phase 6 prep: Move into apps/web/
Phase 2: Create Next.js app
Run non-interactively with all flags:
pnpm create next-app@latest {{name}} --typescript --tailwind --no-linter --no-agents-md --react-compiler --app --no-src-dir --import-alias "@/*" --use-pnpmSets up: TypeScript, Tailwind CSS v4, no linter (Ultracite installs Oxlint and Oxfmt in Phase 5), React Compiler, App Router, Turbopack (default in Next.js 16+), no src/ directory, @/* import alias, pnpm. It may also write a pnpm-workspace.yaml listing which dependency build scripts may run; Phase 6 moves those settings to the turborepo root.
--no-linter and --no-agents-md matter: taking the --biome or --eslint default means uninstalling it again in Phase 5, and --agents-md (on by default) writes an AGENTS.md and CLAUDE.md that Ultracite then overwrites in Phase 5. Next 16.3 adds its own managed block to those files on the first next dev regardless, so nothing is lost by skipping the generator here.
If prompted interactively, select "No, customize settings" and match the flag values above.
After creation, verify:
cd {{name}}
pnpm run devConfirm the app loads at http://localhost:3000.
The generated .gitignore already lists .next/, .env*, and next-env.d.ts. Leave next-env.d.ts ignored: Next.js regenerates it on every dev, build, and typegen, and its contents are an implementation detail.
Phase 2.1: Upgrade to TypeScript 7
create-next-app installs TypeScript 5. Move to TypeScript 7:
pnpm add -D typescript@^7That is the whole step. No config goes with it: in 16.3, next build runs the
project-local tsc CLI by default rather than loading TypeScript's JavaScript
compiler API, which is what makes TypeScript 7 work at all (7 does not ship that
API). experimental.useTypeScriptCli exists only to turn the CLI checker back
off by setting it to false, so a fresh scaffold should never mention it.
Verify:
pnpm exec tsc --version # Version 7.x
pnpm run build # type check runs through tsc, build succeedsBehaviour changes to expect:
- Errors are raw
tscdiagnostics; no Next.js code frames or route-specific rewrites. - The whole
tsconfig.jsonproject is checked, including test files and.next/dev/types. - In VS Code, run "TypeScript: Select TypeScript Version" > "Use Workspace Version" so the editor matches the build.
Phase 2.2: Turn on Instant Navigations
Cache Components and Partial Prefetching make rendering dynamic by default and
let every <Link> prefetch a shared App Shell. Adopting them in an existing app
is a migration; in a new one it is four lines, because there is no legacy
caching to unwind and no <Link prefetch={true}> to audit.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
reactCompiler: true,
// Version-skew protection and cache busting: clients on an old deployment
// hard-reload instead of loading stale chunks. Vercel sets the variable at
// build time; anywhere else it is undefined and the option is inert.
deploymentId: process.env.VERCEL_DEPLOYMENT_ID,
experimental: {
// Runs the React Compiler inside Turbopack as native code instead of
// through the Babel plugin. Experimental in 16.3; see the note below.
turbopackRustReactCompiler: true,
},
};
export default nextConfig;partialPrefetching only works with cacheComponents, so the two ship together
or not at all; next dev and next build refuse the config otherwise.
turbopackRustReactCompiler is the one flag here Next.js still marks
experimental: the 16.3 docs describe it as released "to gather feedback before it
becomes the default". It removes the Babel step from the pipeline, which is where
most of the React Compiler's build cost lives, so the scaffold turns it on, but
tell the user it is experimental. The exit is one line: drop the flag and
pnpm add -D babel-plugin-react-compiler, and reactCompiler: true keeps
working through Babel.
With the Rust compiler on, babel-plugin-react-compiler is not needed.
create-next-app --react-compiler installed it anyway, so remove it now
(pnpm remove babel-plugin-react-compiler), and add no other Babel
transform: any Babel step in the pipeline gives back most of what the Rust path
saves.
What the flags change, and what to write from day one:
- Nothing is cached unless a function says
'use cache'. Add it at the data access, withcacheLifefor how long andcacheTagfor what invalidates it. On Vercel that cache is per function instance; anything that must be shared across instances (the data behind a sitemap or a list page) uses'use cache: remote'. - Four route segment configs are gone:
export const dynamic,dynamicParams,revalidate, andfetchCacheare build errors under Cache Components, in pages and route handlers alike. A'use cache'helper pluscacheLifereplaces them; the directive goes on the helper, never on aGETexport. generateStaticParamsmust return at least one param, or the build raisesempty-generate-static-params. Unlisted params get the App Shell on first visit and upgrade in the background.- Never
await paramsorsearchParamsat the top of a page. Pass the promise into a<Suspense>-wrapped child and await it there, or the shell is tied to one URL. Type the props with the generatedPageProps<'/route'>helper. - Filters on a list page live in path segments (
/projects/tag/[slug]), notsearchParams. Reading search params opts the list out of static rendering and streams it twice; ahas: [{ type: 'query' }]redirect keeps the old query form working. - Same for
cookies()andheaders(): read them inside a boundary so the rest of the page still prerenders. - No
new Date(),Date.now(),Math.random()orcrypto.randomUUID()during render, in server or client components. These are hard build errors. The docs give two fixes:await connection()inside a<Suspense>-wrapped component for a per-request value, or a'use cache'function for one value shared across users (a copyright year, a build stamp). Reading the clock innext.config.tsand passing it throughenvalso works, but thatenvoption is marked legacy; prefer the cached function. useSearchParamsalways needs a<Suspense>boundary, even in a"use client"page.- The previous route stays mounted as hidden DOM during navigation (React
<Activity>), so backgrounds and themes belong to the route, never tobodyorhtml, and any theme switch keys offusePathname()rather than a class onbody. Component state survives back navigation too; reset it in an effect or derive it from the URL. - Keep filesystem-reading modules apart from the constants client components
and
proxy.tsimport. A lazy-loaded footer that imports the project list pulls the whole dataset into the browser bundle, and alib/site.tsthat importsnext/headerscannot be imported by the proxy at all. generateMetadatafollows the same rules. External data goes behind'use cache'inside it; runtime data (cookies(),params) needs a dynamic marker in the page, or the build raisesblocking-prerender-metadata-runtime.
Verify with next dev rather than the build. Instant navigation validation runs
in development only and never fails next build, so a green build is not
evidence. Load each route and confirm the dev overlay reports none. The
Navigation Inspector in the Next.js DevTools ("Pause on navigations") freezes the
page at its shell so you can see what a visitor gets before data streams in.
Phase 3: Install Blode UI components and icons
Blode UI is a third-party shadcn/ui registry served at blode.co/ui (the ui.blode.co subdomain 301s there). Use the hosted @blode namespace flow.
pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest registry add @blode=https://blode.co/ui/r/{name}.json
pnpm add blode-icons-reactThen open components.json and change the icon library before adding any component:
{
"iconLibrary": "blode-icons-react"
}shadcn init writes "iconLibrary": "lucide". Left alone, every component the CLI adds imports from lucide-react, and the replace step below repeats on each add. Now add components:
pnpm dlx shadcn@latest add @blode/buttonOrder matters: registry add must run before any add @blode/... call, or the namespace is unknown and the add fails.
Creates:
components.json: shadcn config, the Blode registry mapping, and the icon librarylib/utils.ts:cn()helper, re-exported from thecnpackagecomponents/ui/button.tsx: button from the Blode registry- CSS variable updates in
app/globals.css
Icons: use blode-icons-react for all icon imports. If any generated file still imports lucide-react, replace the import paths with blode-icons-react. lucide-react is not a dependency of this scaffold; if it appears in package.json, remove it.
Class merging goes through cn, which does the conditional joining and the Tailwind conflict resolution in one function. Do not add clsx or tailwind-merge. class-variance-authority is a separate concern and is still what defines variants.
Phase 4: Install Agentation
pnpm add agentationPatch app/layout.tsx: add import { Agentation } from "agentation"; at the top, and render the component before </body> behind a dev-only guard, {process.env.NODE_ENV === "development" && <Agentation />}. Full pattern:
import { Agentation } from "agentation";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
{process.env.NODE_ENV === "development" && <Agentation />}
</body>
</html>
);
}Phase 4.1: Add Google Analytics (optional)
pnpm add @next/third-parties@latestAdd two lines to the Phase 4 layout: the import, and the <GoogleAnalytics> element as a sibling of <body> (inside <html>, after </body>), which is where the Next.js third-parties guide places it:
import { GoogleAnalytics } from "@next/third-parties/google";
// ...inside <html>, after </body>:
<GoogleAnalytics gaId="G-XYZ" />Replace "G-XYZ" with your GA4 measurement ID.
For any other analytics or error-tracking SDK (PostHog, Sentry), initialise it in
instrumentation-client.ts at the app root rather than in a client component.
The file runs before the app hydrates, needs no exports, and keeps the SDK out of
the component tree. Lessons from a production PostHog setup that apply to any
SDK:
- Guard
initagainstlocalhost,127.0.0.1, and*.localhost(named dev origins from portless) so development sessions do not land in production data. - Point
api_hostat a reverse proxy on your own domain, set from aNEXT_PUBLIC_variable, so ad blockers that list the vendor's hosts do not drop the data; setui_hostto the vendor's real app so its toolbar and links still work. The proxy origin then belongs in the CSPscript-src(the SDK lazy-loads chunks),connect-src, andworker-src 'self' blob:. - Filter
before_sendfor browser-extension exceptions (chrome-extension://,runtime.sendMessage,Extension context invalidated) and framework noise (AbortError,Script error.,Internal Next.js error) or the error inbox is unusable within a week. - Server-side captures go straight to the ingestion host (a server request has
no blocker to get past) and reuse the browser cookie's
distinct_idso conversions attach to the same person; send them withafter(). - A build-time source-map upload wrapper that throws when its credentials are
missing must be applied conditionally, or a fresh clone and every Vercel
build without the variables fails on
Failed to load next.config.ts.
Phase 5: Install Ultracite
- Run Ultracite init non-interactively (Oxlint + Oxfmt + Lefthook). Scaffolding with
--no-lintermeans there is no Biome or ESLint config to remove first; if you inherited one from an older scaffold, delete it and uninstall the dependency before this step, or two linters fight over the same files.
pnpm dlx ultracite@latest init \
--linter oxlint \
--frameworks next react \
--js-plugins @shadcn/lint \
--integrations lefthook \
--agents universal \
--pm pnpm \
--skip-install \
--quietFlag notes:
--frameworkstakes space-separated values (next react), not commas; commas fail validation.--js-plugins @shadcn/lintis how Ultracite 7.12+ registersultracite/oxlint/shadcn. It is opt-in and required here. Older CLIs reject the value or skip the preset; confirmultraciteis ≥ 7.12 after install.--agents universalwritesAGENTS.mdwith the Ultracite code standards. Without it,--quietskips the agent prompt and no file is written.--skip-installlets you review the generatedpackage.jsonchanges before installing.- Omit
--quietto confirm the generated file list interactively.
Sets up (verified against a real ultracite@7.12.0 init run with these flags):
oxlint.config.ts: extendsultracite/oxlint/{core,next,react,shadcn}and hoistsjsPlugins: shadcn.jsPlugins. Phase 5.1 only edits this file if that import is missing.oxfmt.config.ts: extendsultracite/oxfmtlefthook.yml: a pre-commit hook. This copy is temporary. Phase 6 replaces it with a root-level file scoped toapps/web/, because git readslefthook.ymlonly from the directory that holds.git.AGENTS.mdwith the Ultracite code standards- In
package.json:checkandfixscripts,"type": "module", andoxlint,oxfmt,lefthook(oftenlatest) plus@shadcn/lintand a pinnedultracite. Nopreparescript: with--skip-installthe lefthook install step that would write it is skipped, and the rootpackage.jsonin Phase 6 owns it instead.
- Install, pin, and verify:
pnpm install
pnpm exec ultracite fix # oxfmt --write + oxlint --fix
pnpm exec ultracite check # oxfmt --check + oxlintBoth pass with zero errors. Leave the generated extends (including shadcn) and ignorePatterns intact. Replace the latest ranges in devDependencies with the versions pnpm install resolved (pnpm ls ultracite oxlint oxfmt lefthook @shadcn/lint --depth=0), so the hook and CI run the same binaries. ultracite must be ≥ 7.12. Use AGENTS.md directly and remove any generated duplicate CLAUDE.md wrapper; Claude Code supports AGENTS.md through its built-in mod. On the first next dev run from a coding agent's shell, Next 16.3 appends its managed nextjs-agent-rules block to AGENTS.md; content outside the markers is preserved, CLAUDE.md is left alone when it exists, and nothing is written from a plain terminal.
Phase 5.1: Enable ultracite/oxlint/shadcn
Ultracite 7.12 ships an opt-in ultracite/oxlint/shadcn preset on top of @shadcn/lint. It enables all six design-system rules at error with the upstream allow: ["layout"] policy, and relaxes the component-authoring rules inside **/components/ui/**. This phase runs before the turbo move, in {{name}}/. After Phase 6 the same files live in apps/web/; if you are wiring this into an already-moved tree, cd apps/web and edit there, never at the turborepo root.
Requires Ultracite ≥ 7.12, Oxlint ≥ 1.80 (JS plugins), and Node ≥ 20.19. Prefer the Phase 5 init flag. Do not fall back to jsPlugins: ["@shadcn/lint"] plus a hand-rolled starter-only no-restyle snippet.
- Confirm
oxlint.config.tslooks like this (framework import order may follow--frameworks next react). Keepcore,next, andreact; addshadcn.jsPlugins: shadcn.jsPluginsre-declares the plugin on the root config so Knip does not flag@shadcn/lintas unused.
import { defineConfig } from "oxlint";
import core from "ultracite/oxlint/core";
import next from "ultracite/oxlint/next";
import react from "ultracite/oxlint/react";
import shadcn from "ultracite/oxlint/shadcn";
export default defineConfig({
extends: [core, next, react, shadcn],
ignorePatterns: core.ignorePatterns,
jsPlugins: shadcn.jsPlugins,
});If init ran without the flag, or ultracite was older than 7.12, upgrade (pnpm add -D ultracite@latest), install the plugin (pnpm add -D @shadcn/lint), and add the shadcn import, extends entry, and jsPlugins hoist yourself. Re-running pnpm dlx ultracite@latest init with the same flags (including --js-plugins @shadcn/lint) also updates an existing config.
The preset already turns shadcn/no-restyle, shadcn/no-arbitrary-values, and shadcn/require-static-classes off for **/components/ui/** (definitions must restyle). Do not duplicate that override when the default alias is in use. If components.json aliases.ui points elsewhere, add a matching override for that path and, if needed, settings.shadcn.ui on this root config (Oxlint does not merge settings from extended configs).
Pin
@shadcn/lint(andoxlintif you bumped it) to the resolved versions, the same pin Phase 5 applied toultracite,oxfmt, andlefthook.Append this to
AGENTS.md(outside any later Next-managed markers):
## Design-system lint
Ultracite extends `ultracite/oxlint/shadcn` (`@shadcn/lint`). After UI
changes, run `pnpm exec ultracite check`. Findings name the variant, token,
or file to use instead. Autofix with `pnpm exec ultracite fix`; remaining
diagnostics with `pnpm exec ultracite fix --codex` (or `--claude`) when
that CLI is available. Call sites may add layout classes (`mt-4`, `w-full`);
appearance belongs in `components/ui/`.- Verify from this directory, not the parent:
pnpm exec ultracite checkZero errors. A plugin-load failure usually means Ultracite is older than 7.12, Oxlint is older than 1.80, @shadcn/lint is not installed in this package, or shadcn is missing from extends.
Agents see design-system errors through the same Ultracite check that CI runs. ultracite fix applies mechanical autofixes; ultracite fix --codex (or --claude) hands the rest to the local agent CLI, file by file.
Phase 6 prep: Move into apps/web/
From the parent directory of {{name}}:
mkdir -p {{name}}-turbo/apps
mv {{name}} {{name}}-turbo/apps/web
mv {{name}}-turbo {{name}}
rm -rf {{name}}/apps/web/node_modules {{name}}/apps/web/pnpm-lock.yamlThe app is now at {{name}}/apps/web/. Root config files are generated in {{name}}/ during Phase 6. The app's own node_modules and lockfile go because the root pnpm install replaces them with one workspace lockfile; a stale apps/web/pnpm-lock.yaml would make Next.js warn about multiple lockfiles and can pick the wrong workspace root. Leave apps/web/pnpm-workspace.yaml (if present) until Phase 6 copies its settings to the root. oxlint.config.ts and the @shadcn/lint dependency move with the app; do not reinstall them at the turborepo root.