How to Do Web Development in 2026
Learn what takes years in a week. The secret isn't learning more, it's starting with the right foundation. ZeroStarter teaches you practices that take years to learn, in a week.
Learn What Takes Years in a Week.
The secret isn't learning more. It's starting with the right foundation.
Here's an uncomfortable truth about web development in 2026: The gap between junior and senior developers isn't about knowing more syntax or frameworks. It's about understanding practices, the invisible systems that separate hobby projects from production software.
How do you structure a codebase that scales? How do you ensure type safety across your entire stack? How do you manage environment variables without production surprises? How do you set up a development workflow that catches problems early? How do you automate releases so changelogs write themselves?
These practices take years to learn through painful mistakes. Or you can absorb them by working with a codebase that already implements them.
ZeroStarter is practices as a service. It's a living reference implementation. Study it, modify it, make it yours. The patterns matter more than the specific choices.
Ready to dive in? Get started with ZeroStarter or explore the documentation.
Project Architecture
The principle: Code should be organized with clear boundaries. Shared logic should live in shared packages, not be copy-pasted across projects.
Why it matters: When authentication logic exists in one place, fixing a bug fixes it everywhere. When it's duplicated, you fix it in three places and miss the fourth. When your frontend and backend live in separate repos with no shared types, they drift apart.
How ZeroStarter approaches this
ZeroStarter uses a monorepo, multiple applications and packages in a single repository:
├── api/hono/ # Backend API (Hono)
├── web/next/ # Frontend application (Next.js)
└── packages/
├── auth/ # Better Auth: sessions, OAuth, organizations
├── cli/ # `zerostarter` init CLI that scaffolds a fork
├── config/ # Shared build config + brand/site metadata
├── db/ # Database schema (single source of truth)
└── env/ # Environment variable validationThe API imports auth logic from @packages/auth. The database schema in @packages/db is the single source of truth. Brand and site metadata live once in @packages/config/site, so renaming a fork touches a single file. TypeScript configuration is consistent across all packages.
Turborepo manages the build graph. Change packages/db? It rebuilds dependent packages. Unchanged packages use cache. Builds stay fast as the codebase grows.
What you can learn
Monorepos aren't the only answer:
- Turborepo, Nx, Lerna: Different monorepo tools with different trade-offs
- Separate repositories: Simpler for small teams, harder to keep in sync
- npm packages: Publish shared code to a registry
- Polyrepo with shared types: Keep repos separate but share type definitions
The practice is clear boundaries and code reuse. How you achieve it depends on your team size, deployment needs, and how much shared code you have.
The Tech Stack
Before diving into practices, here's what ZeroStarter uses, and why these choices work together:
| Layer | Choice | Why |
|---|---|---|
| Runtime | Bun | Fast installs, native TypeScript, modern tooling |
| Build | Turborepo | Incremental builds, dependency-aware caching |
| Frontend | Next.js | App Router, Server Components, mature ecosystem |
| Backend | Hono | Ultra-fast, edge-compatible, type-safe RPC |
| Database | PostgreSQL/Drizzle | Type-safe ORM, SQL-like syntax, zero runtime overhead |
| Auth | Better Auth | Self-hosted, orgs + teams + admin, magic link, TS-first |
| Styling | Tailwind + Shadcn | Utility CSS, accessible components, copy-paste friendly |
| Validation | Zod | Runtime + compile-time types from one schema |
| Linting | Oxlint + Oxfmt | 50-100x faster than ESLint/Prettier |
| API Docs | Scalar | Interactive API docs from OpenAPI, auto-generated |
| Docs | Fumadocs | MDX-based, generates AI-friendly documentation |
| Analytics | PostHog | Product analytics, feature flags, session replay |
These aren't the only valid choices. Next.js could be Remix or SvelteKit. Hono could be Express or Fastify. Drizzle could be Prisma. The practices that follow apply regardless of your specific stack.
See it in action: Check out the Architecture documentation to understand how these pieces fit together.
Type-Safe API Communication
The principle: The contract between frontend and backend should be enforced at compile time. If the backend changes a response shape, the frontend should know immediately, not when users report errors.
Why it matters: The backend returns { userName: "John" }. The frontend expects { username: "john" }. Different casing, different property name. Without type safety, this becomes a runtime error, possibly only for some users, making it hard to reproduce.
How ZeroStarter approaches this
ZeroStarter uses Hono RPC to share types between backend and frontend without code generation:
Backend defines routes with types:
// Illustrative route. ZeroStarter validates with @hono/standard-validator (sValidator).
import { Hono } from "hono"
import { sValidator } from "@hono/standard-validator"
import { z } from "zod"
const itemSchema = z.object({
name: z.string().min(1),
price: z.number().positive(),
})
export const v1Router = new Hono()
.get("/items", async (c) => {
const data = await db.query.items.findMany()
return c.json({ data })
})
.post("/items", sValidator("json", itemSchema), async (c) => {
const body = c.req.valid("json") // Typed from the Zod schema
const [data] = await db.insert(items).values(body).returning()
return c.json({ data })
})Frontend infers types automatically:
// web/next/src/lib/api/client.ts
import type { AppType } from "@api/hono"
import { hc } from "hono/client"
export const apiClient = hc<AppType>(config.api.url, {
init: { credentials: "include" },
}).apiChange the backend response? TypeScript errors appear in the frontend immediately. Rename a field? Every call site is flagged.
What you can learn
Multiple approaches achieve this:
- Hono RPC: Types inferred from route definitions, no codegen
- tRPC: Similar approach, tight React Query integration
- GraphQL + codegen: Schema-first, generates TypeScript types
- OpenAPI + codegen: REST with generated clients from spec
- Shared types package: Manual but explicit control
The practice is type safety across the network boundary. Whether you infer types or generate them, the goal is the same: catch mismatches at compile time.
Learn more: See the Type-Safe API documentation for complete examples and patterns.
API Response Format
The principle: Every API response should follow a consistent format. Success and error responses should be predictable and easy to handle.
Why it matters: A user reports "something went wrong." Without standardized error responses, debugging becomes guesswork. With consistent formats, error handling is straightforward across your entire application.
How ZeroStarter approaches this
Standardized response format for all endpoints:
// Success response
{ "data": { "message": "ok", "version": "1.0.0" } }
// Error response
{ "error": { "code": "NOT_FOUND", "message": "Not Found" } }Routes throw — an ApiError for domain codes, or a Hono HTTPException for standard ones — and a single global handler turns every thrown error into the envelope:
// api/hono/src/lib/error.ts (wired into app.onError)
export const errorHandler = (err: Error, c: Context) => {
// Domain errors carry their own code/extra; check before HTTPException since ApiError extends it.
if (err instanceof ApiError) {
return jsonError(c, err.status, err.code, err.message, err.extra)
}
if (err instanceof z.ZodError) {
return jsonError(c, 400, "VALIDATION_ERROR", "Invalid request payload", { issues: err.issues })
}
// Honor the status Hono already chose (e.g. malformed JSON is a 400, not a 500)
if (err instanceof HTTPException) {
const code = httpExceptionCodes[err.status] ? httpExceptionCodes[err.status] : "ERROR"
return jsonError(c, err.status, code, err.message)
}
const message = isLocal(env.NODE_ENV) ? err.message : "Internal Server Error"
return jsonError(c, 500, "INTERNAL_SERVER_ERROR", message)
}Every error follows the same shape. Zod validation errors include the specific issues. Server errors are masked in production. Clients always know what to expect.
Consuming responses on the frontend is symmetrical. The client wraps any call in unwrap, which returns a typed { data, error } result and never throws:
// web/next/src/lib/api/client.ts
const { data, error } = await unwrap(apiClient.v1.session.$get())
if (error) {
// error.code and error.message are always present;
// validation failures also carry error.issues
}Exactly one of data or error is non-null, so call sites branch once and TypeScript narrows the rest.
What you can learn
Response standardization approaches vary:
- Wrapper types:
{ data }or{ error }at the top level - Result helpers: a client
unwrapso callers never wrap calls in try/catch - HTTP status codes: Use appropriate codes (400, 401, 404, 429, 500)
- Error codes: Machine-readable codes like
VALIDATION_ERROR - Error tracking: Sentry, Bugsnag, LogRocket for production monitoring
The practice is consistent, predictable API responses. When something fails, the client knows exactly how to handle it.
API Documentation
The principle: Your API should document itself. The spec, the interactive explorer, and the error responses should all come from the same source as the code, so they can never drift.
Why it matters: Hand-written API docs rot. The moment a route changes and the docs don't, every consumer is working from a lie. Generated docs stay honest because they are the code.
How ZeroStarter approaches this
Routes are described with hono-openapi, which emits an OpenAPI document at /api/openapi.json. Scalar renders it as an interactive reference at /api/docs:
// api/hono/src/index.ts
import { Scalar } from "@scalar/hono-api-reference"
import { openAPIRouteHandler } from "hono-openapi"
app
.basePath("/api")
// ...routes...
.get(
"/openapi.json",
openAPIRouteHandler(app, {
// 429 + 500 reach every GET/POST; routes add 400/401 in their own responses
defaultOptions: {
GET: { responses: globalErrorResponses },
POST: { responses: globalErrorResponses },
},
}),
)
.get("/docs", Scalar({ url: "/api/openapi.json" }))Error responses are scoped per route, so the docs only advertise statuses a route can actually return. The helpers live in api/hono/src/lib/error.ts:
// a validated route behind auth advertises exactly what it can return
responses: {
...authErrorResponses, // 401
...validationErrorResponses, // 400, includes the per-field issues array
}So /api/health documents 200/429/500, /api/v1/* adds 401, and validated routes add 400, each status with its own code/message example.
What you can learn
API documentation approaches vary:
- Spec-first: write OpenAPI by hand, then generate the server
- Code-first: derive the spec from typed routes (ZeroStarter's approach)
- Explorers: Scalar, Swagger UI, Redoc, Stoplight
- Contract tests: verify the running API matches its spec
The practice is documentation generated from code, so it never lies about what the API does.
Authentication
The principle: Authentication should be secure by default, flexible enough to extend, and simple enough to understand. Users shouldn't think about security, it should just work.
Why it matters: Auth bugs are security bugs. A session that doesn't expire, a token that leaks, a callback URL that's not validated, these become vulnerabilities. Using a battle-tested library with secure defaults protects you from mistakes.
How ZeroStarter approaches this
ZeroStarter uses Better Auth. OAuth providers are enabled only when their credentials are present, so a fork can ship with GitHub, Google, both, or none (relying on magic link):
// packages/auth/src/index.ts
// A provider is enabled only when both of its OAuth credentials are set.
export const enabledSocialProviders = [
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? (["github"] as const) : []),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? (["google"] as const) : []),
]
export const auth = betterAuth({
baseURL: env.HONO_APP_URL,
trustedOrigins: env.HONO_TRUSTED_ORIGINS,
database: drizzleAdapter(db, {
provider: "pg",
schema: { user, session, account, organization /* ... */ },
}),
plugins: [openAPIPlugin(), organizationPlugin({ teams: { enabled: true } }), adminPlugin()],
socialProviders: {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } }
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } }
: {}),
},
})Multi-tenancy out of the box. The organization plugin (with teams) and the admin plugin ship enabled, so organizations, teams, members, and roles exist from day one.
Frontend auth client mirrors the server plugins:
import { magicLinkClient, organizationClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: `${config.api.url}/api/auth`,
plugins: [magicLinkClient(), organizationClient({ teams: { enabled: true } })],
})The UI reads an enabledProviders list, so OAuth buttons render only for configured providers, and the magic-link field shows only when its server plugin is registered.
Cross-subdomain cookies for multi-app authentication:
...(cookieDomain && {
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: cookieDomain,
},
},
})What you can learn
Auth approaches vary:
- Better Auth: Self-hosted, flexible, TypeScript-first
- Auth.js: Popular, many providers, Next.js integration
- Lucia: Lightweight, database-agnostic
- Clerk, Auth0: Managed services, less control
- Custom: Full control, full responsibility
The practice is secure, tested authentication. Whether self-hosted or managed, the auth system should be secure by default and auditable.
Try it yourself: Clone ZeroStarter and run
bun dev. Configure GitHub and/or Google OAuth, or run with magic link only. See the Setup guide.
Environment Variable Management
The principle: Environment variables should be validated at startup and typed throughout your code. A missing variable should crash the app immediately with a clear error, not fail silently during a request.
Why it matters: process.env.POSTGRES_URL returns string | undefined. If it's undefined, when do you find out? Maybe immediately. Maybe five minutes later when the first database query runs. Maybe in production when a specific code path executes.
How ZeroStarter approaches this
ZeroStarter uses @t3-oss/env-core with Zod schemas:
// packages/env/src/api-hono.ts
export const env = createEnv({
server: {
NODE_ENV,
HONO_APP_URL: z.url(),
HONO_PORT: z.coerce.number().default(4000),
HONO_RATE_LIMIT: z.coerce.number().default(60),
HONO_RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000),
HONO_TRUSTED_ORIGINS: z
.string()
.transform((s) => s.split(",").map((v) => v.trim().replace(/\/$/, "")))
.pipe(z.array(z.url())),
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
HONO_APP_URL: process.env.HONO_APP_URL,
HONO_PORT: process.env.HONO_PORT,
HONO_RATE_LIMIT: process.env.HONO_RATE_LIMIT,
HONO_RATE_LIMIT_WINDOW_MS: process.env.HONO_RATE_LIMIT_WINDOW_MS,
HONO_TRUSTED_ORIGINS: process.env.HONO_TRUSTED_ORIGINS,
},
emptyStringAsUndefined: true,
skipValidation: process.env.SKIP_ENV_VALIDATION === "true",
})Each package has its own env file, importing only what it needs. The auth package can't accidentally access database credentials. The frontend can't access server secrets. Those HONO_RATE_LIMIT values feed a global rate limiter (hono-rate-limiter) that returns 429 TOO_MANY_REQUESTS, with authenticated requests granted twice the limit.
What you can learn
Validation approaches vary:
- t3-env: Zod-based, popular in Next.js projects
- envalid: Similar concept, different API
- Infisical: Secrets management with syncing
- Platform validation: Vercel, Railway have built-in checks
The practice is validated, typed environment configuration. The specific library matters less than having validation at all. Fail fast, fail clearly.
Deep dive: Learn how ZeroStarter organizes environment variables across packages in the Environment documentation.
Development Workflow
The principle: Your workflow should prevent mistakes, not just catch them. Commits should be meaningful. Branches should protect production.
Why it matters: Direct pushes to production cause outages. Commits like "fix stuff" and "wip" make debugging impossible. A workflow with guardrails prevents these problems without slowing you down.
Branch Strategy
ZeroStarter uses canary (development) and main (production) branches. Push to canary, a draft PR is automatically created for main. Merging that PR with a merge commit lets the release automation cut a version. This ensures every production change goes through review.
Branch protection as code via GitHub rulesets:
// .github/rulesets/canary.json
{
"name": "canary",
"enforcement": "active",
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"allowed_merge_methods": ["squash"]
}
}
]
}Rulesets are version-controlled. Branch protection is documented. New team members understand the workflow by reading the config.
Commit Conventions
ZeroStarter enforces Conventional Commits:
feat(auth): add Google OAuth support
fix(api): handle null user in session
chore(deps): update dependencies
refactor(web): extract form validation hookBut your team might prefer ticket references, emoji conventions, or freeform messages. The practice is meaningful, consistent commit messages. The format is a team decision.
Code Quality Gates
The principle: Catch problems before they enter the codebase. A bug caught at commit time costs minutes. The same bug in production costs hours, plus customer trust.
Why it matters: Code review is for logic and architecture. Humans shouldn't spend time catching formatting issues, linting errors, or type mismatches, that's what automation is for.
How ZeroStarter approaches this
Lefthook runs checks before commits and pushes:
pre-commit:
piped: true # Run in sequence, stop on first failure
commands:
lint-staged:
run: bunx lint-staged --verbose
stage_fixed: true # Auto-stage formatted files
build:
run: bun run build
commit-msg:
commands:
commitlint:
run: bunx commitlint --edit {1}
pre-push:
commands:
audit:
run: bun audit --audit-level high
only:
- ref: canary # full advisory audit on canary (network-dependent, off the commit path)IDE configuration is shared via .vscode/settings.json:
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}Everyone uses the same formatter. Formatting debates are over. Code looks the same regardless of who wrote it.
What you can learn
The specific checks vary by project. The practice is automated quality gates. Where you place them (pre-commit, pre-push, CI only) depends on how fast they run and how much friction you'll accept.
See the full setup: The Code Quality documentation explains every tool, config file, and why they're configured that way.
Code Review Automation
The principle: Automate the tedious parts of code review. Let humans focus on logic, architecture, and edge cases.
Why it matters: Reviewers have limited attention. If they're spending time on "this file needs a newline at the end," they're not catching "this query could return stale data under load."
How ZeroStarter approaches this
CodeRabbit for AI-powered code review:
# .coderabbit.yaml
reviews:
collapse_walkthrough: false
issue_enrichment:
auto_enrich:
enabled: true
labeling:
auto_apply_labels: trueAuto-labeling based on changed files:
# PR touches api/hono/ -> gets @api/hono label
# PR touches package.json -> gets @dependencies label
# PR touches .github/workflows/ -> gets @workflows labelLabels are created automatically. Reviews can be filtered by area. You know at a glance what a PR affects.
In-repo audit reports as reference:
.github/audit/
└── 2026-06-21-docs-audit.md # dated audit report with findingsPast audits are documented. Patterns are captured. New team members learn from history.
What you can learn
Review automation varies:
- CodeRabbit, Codium: AI-powered review
- Danger.js: Custom automation rules
- CODEOWNERS: Route reviews to experts
- Labeler actions: Tag PRs by path
The practice is systematic, consistent code review. Automate what can be automated. Free humans for what requires judgment.
CI/CD Pipelines
The principle: Every change should pass automated checks. Machines verify formatting, types, and tests. Humans review logic and architecture.
Why it matters: Reviewers have limited attention. Don't waste it on "you forgot a semicolon" when automation can catch that.
How ZeroStarter approaches this
GitHub Actions runs on every PR:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun audit --audit-level high
- run: bun run lint
- run: bun run buildPRs are automatically labeled based on changed files. Approval status is tracked (0/1 to APPROVED). You always know the state of every PR at a glance.
What you can learn
CI platforms abound. Common checks include linting, type checking, tests, builds, security scanning, and preview deployments. The practice is automated verification before merge.
Halfway there! You've learned the core development practices. Ready to see them in action? Clone ZeroStarter and explore the workflows firsthand.
SEO and Social Sharing
The principle: Your app should be discoverable. Search engines and social platforms should understand your content. This shouldn't require manual work for every page.
Why it matters: A blog post with no OG image gets ignored on Twitter. A site with no sitemap gets indexed slowly. Manual meta tags are forgotten. Automation ensures consistency.
How ZeroStarter approaches this
Auto-generated sitemap from content:
// web/next/src/app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const docsPages = docsSource.getPages()
const blogPages = blogSource.getPages()
return [
{ url: baseUrl, priority: 1 },
...docsPages.map((page) => ({ url: `${baseUrl}${page.url}`, priority: 0.9 })),
...blogPages.map((page) => ({ url: `${baseUrl}${page.url}`, priority: 0.9 })),
]
}Add a doc or blog post? It's automatically in the sitemap. No manual updates needed.
robots.txt configuration:
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: "*", allow: "/", disallow: ["/api/", "/dashboard/"] }],
sitemap: `${baseUrl}/sitemap.xml`,
}
}Dynamic OG images generated per page:
// web/next/src/app/og/home/route.tsx
export async function GET() {
return new ImageResponse(
<div
style={
{
/* ... */
}
}
>
<div>{site.name}</div>
<div>{site.description}</div>
</div>,
{ width: 1200, height: 630 },
)
}Share a page on social media? It has a proper preview image. No manual image creation.
What you can learn
SEO approaches vary:
- Framework built-ins: Next.js Metadata API, Nuxt SEO
- OG image libraries: @vercel/og, satori
- Sitemap generators: next-sitemap, manual
- Structured data: JSON-LD for rich snippets
The practice is automated SEO. Sitemaps, meta tags, and social images should generate themselves.
Dependency Management
The principle: Dependencies should be consistent and secure. The same package should have the same version everywhere.
Why it matters: Package A uses zod@4.4. Package B uses zod@4.3. They share types. Now you have subtle incompatibilities that only appear in specific combinations.
How ZeroStarter approaches this
Bun's catalog feature centralizes versions:
// Root package.json
{
"catalog": {
"zod": "^4.4.3",
"hono": "^4.12.26"
}
}
// Any workspace package.json
{
"dependencies": {
"zod": "catalog:",
"hono": "catalog:"
}
}A script runs automatically on commits, migrating consistent versions to the catalog.
Shadcn update script keeps UI components current:
# bun run shadcn:update -> .github/scripts/shadcn-update.sh
bunx shadcn@latest add -a # re-scaffold all components
bun .github/scripts/shadcn-customize.ts # re-apply local customizations
bun run formatUI components stay updated. Accessibility improvements flow in. Security patches are applied, and local customizations are re-applied on top.
What you can learn
The practice is version consistency and automated updates. How you achieve it depends on your package manager.
Release Management
The principle: Releases should document themselves. Changelogs should be accurate. Version numbers should be meaningful.
Why it matters: Manual changelogs are incomplete. When something breaks in production, you need to know exactly what changed.
How ZeroStarter approaches this
When the canary to main release PR merges, changelogen automatically generates a changelog:
## v0.2.0
### 🚀 Features
- **auth**: add Google OAuth provider (a1b2c3d)
### 🐛 Bug Fixes
- **api**: handle null user in session (d4e5f6g)
### Contributors
@nrjdalalThe workflow bumps the version, updates CHANGELOG.md, commits it back to canary, tags the release, and publishes a GitHub release with the same notes.
What you can learn
The practice is automated, accurate release documentation. Whether you auto-generate or write by hand, the goal is knowing exactly what shipped.
Deployment
The principle: Deployments should be reproducible. "Works on my machine" shouldn't be an excuse.
Why it matters: Environment differences cause production-only bugs. Containerization eliminates these surprises.
How ZeroStarter approaches this
Docker Compose for local production testing:
services:
api:
build:
context: .
dockerfile: api/hono/Dockerfile
environment:
- INTERNAL_API_URL=http://api:4000
web:
build:
context: .
dockerfile: web/next/Dockerfile
environment:
- INTERNAL_API_URL=http://api:4000Services communicate via Docker's internal network. INTERNAL_API_URL enables the api container to rewrite Postgres URLs for Docker networking, and the web container to make server-side API calls directly.
Vercel for production: Frontend and backend deploy as separate projects from the same repository.
What you can learn
The practice is reproducible, automated deployments. Whether containers, serverless, or traditional, deployments should be consistent.
Deploy today: ZeroStarter includes ready-to-use Docker and Vercel configurations. See the Docker and Vercel deployment guides.
Developer Experience
The principle: Development should be fast and informative. Errors should be clear. The feedback loop should be tight.
Why it matters: Slow builds kill productivity. Cryptic errors waste hours. Good DX keeps developers in flow.
How ZeroStarter approaches this
Dev tools in non-production environments:
// Shows viewport size, breakpoints, deployment links
{
!isProduction(env.NODE_ENV) && <DevTools />
}The component shows current breakpoint (SM, MD, LG), viewport dimensions, and quick links to Vercel deployments. Responsive debugging is instant.
React Query DevTools for data debugging:
<ReactQueryDevtools buttonPosition="top-right" />See every query, its state, cache status, and refetch triggers. Data issues are visible.
Centralized config, split by concern:
// web/next/src/lib/config.ts (runtime/env-derived values only)
export const config = {
app: {
url: env.NEXT_PUBLIC_APP_URL,
version: BUILD_VERSION,
},
api: {
url: env.NEXT_PUBLIC_API_URL,
internalUrl: getInternalApiUrl(), // server-only, for direct API calls
},
} as constRuntime values live here. Brand (name, description, URLs) lives once in @packages/config/site, so a fork rebrands by editing a single file.
What you can learn
DX tools vary:
- Dev tools: Custom components, browser extensions
- Hot reloading: Vite, Next.js Fast Refresh
- Error overlays: Better error messages in development
- Mock servers: MSW for API mocking
The practice is optimized developer experience. Fast feedback. Clear errors. Tools that help rather than hinder.
AI-Assisted Development
The principle: AI tools are only as good as the context you give them. A well-structured codebase produces better AI suggestions.
Why it matters: Ask AI to "add an API endpoint" in a messy codebase, and you get messy code. Ask the same in a structured codebase with consistent patterns, and the AI follows those patterns.
How ZeroStarter approaches this
AGENTS.md (with a CLAUDE.md companion) tells AI tools about project structure and conventions.
.agents/skills/ (mirrored to .claude/skills/) hold structured, runnable instructions for common tasks:
.claude/skills/
├── gh-commit/ # atomic conventional commits
├── api-endpoint/ # add a typed Hono endpoint
├── db-migration/ # Drizzle schema changes
└── dev/ # run and verify the dev stackAgent-friendly local sign-in. A Login (agents) action signs in as a fixed LocalAgent user via /api/agents/sign-in-as (local-only and trusted-origin gated), so an agent can exercise authenticated routes without a real OAuth dance.
llms.txt auto-generates documentation optimized for AI:
| Endpoint | Content |
|---|---|
/llms.txt | Documentation index with links |
/llms-full.txt | Complete docs in one file |
Point an AI at the full docs and it understands your entire project.
What you can learn
The practice is intentional context for AI tools. Whether .agents/skills/, AGENTS.md, or well-structured code, the goal is helping AI understand your patterns.
AI-ready from day one: ZeroStarter's documentation is available at /llms.txt. Point your AI assistant there and it understands the entire project.
Getting Started
# Scaffold into a new, empty directory (its name becomes your project name)
npx zerostarter@latest init
# Set up environment
cp .env.example .env # edit with your values
# Initialize the database
bun run db:generate
bun run db:migrate
# Start development
bun devzerostarter init fetches the latest ZeroStarter, removes the sample content, rebrands it to your project, and installs dependencies. Frontend runs on localhost:3000. Backend runs on localhost:4000. Types flow end-to-end. Commits are validated. Quality gates are active.
Explore the code. Read the patterns. Modify them for your needs.
This is just the beginning. Every practice in this article is implemented in ZeroStarter, fully documented, and ready for you to learn from. The complete documentation covers installation, architecture, deployment, and more.
- Documentation: zerostarter.dev/docs
- AI-Ready Docs: zerostarter.dev/llms.txt
- GitHub: github.com/nrjdalal/zerostarter
- Updates: @nrjdalal
MIT Licensed. Learn from it. Modify it. Make it yours.