Loomis
Getting started

Code recipes

Eight changes people actually make, written out, with the file to touch and the command to check it.

This page assumes you are comfortable reading TypeScript and that you have the folder open in an editor. Every recipe follows the same rhythm: the file to add, the file to edit beside it, and the gate that tells you whether it worked.

Run the gates after each recipe, not at the end of the day

pnpm typecheck, pnpm lint and pnpm test are clean as shipped, which is what makes them useful: a failure is something you did in the last few minutes, not archaeology.

1. Add a page to this manual

Two edits: a file, and the folder's list.

---
title: Backups, in your own words
description: What to do on a Friday afternoon.
icon: Download
---

Your content here, in Markdown.

Then add the name of the file, without the extension, to content/docs/<folder>/meta.json:

{
  "title": "Running and maintaining",
  "pages": ["hosting", "updating", "your-new-page"]
}

The sidebar, the search index, the table of contents and the previous/next links are all derived from that folder, so there is nothing else to register. An icon in the frontmatter is a name from lucide.dev; the same works in a meta.json for a folder.

2. Add a page to the marketing site

// app/(marketing)/(site)/changelog/page.tsx
import type { Metadata } from "next"

export const metadata: Metadata = {
  title: "Changelog",
  description: "What changed, and when.",
}

export default function Page() {
  return (
    <main className="mx-auto w-full max-w-3xl px-6 py-24">
      <h1 className="font-heading text-4xl font-semibold tracking-tight">
        Changelog
      </h1>
      <p className="mt-4 text-muted-foreground">
        Every release, newest first.
      </p>
    </main>
  )
}

A page in the (site) group inherits the footer. A page in legal/ deliberately does not. To put it in the navigation, add one entry to lib/marketing/nav.ts.

3. Add a screen to the admin panel

Three edits, and the third one is the whole pattern.

// components/admin/your-panel.tsx
"use client"

export function YourPanel() {
  return <div className="flex flex-col gap-6">{/* the screen */}</div>
}
// lib/admin-nav.ts, in the group it belongs to
{
  slug: "your-screen",
  title: "Your screen",
  blurb: "One sentence, which is what the section list shows.",
  icon: Gauge,
  holds: ["What is on the screen", "And what else is on it"],
  live: true,
}
// app/(admin)/admin/[section]/page.tsx, in the switch at the bottom
case "your-screen":
  return <YourPanel />

The route is one file with a switch, so a slug with no case is a not-found page rather than a blank screen. That is deliberate: a screen that does not exist should look like it does not exist.

4. Add a field, and read it

// convex/schema.ts
export default defineSchema({
  projects: defineTable({
    // existing fields
    pinned: v.optional(v.boolean()),
  }),
})
// convex/projects.ts
import { v } from "convex/values"

import { mutation, query } from "./_generated/server"

export const pinned = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query("projects")
      .filter((row) => row.eq(row.field("pinned"), true))
      .collect()
  },
})

export const pin = mutation({
  args: { id: v.id("projects"), pinned: v.boolean() },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, { pinned: args.pinned })
  },
})
npx convex dev --once   # push the schema and the functions, then exit

A new optional field needs no migration. A required one does, and the way to do it is the way this folder already does it: add it as optional, backfill with a function, then tighten it in a later change.

5. Add an API route that needs a signed-in person

// app/api/pinned/route.ts
import { NextResponse } from "next/server"

import { api } from "@/convex/_generated/api"
import { fetchAuthQuery, isAuthenticated } from "@/lib/auth-server"

export async function GET() {
  if (!(await isAuthenticated())) {
    return NextResponse.json({ error: "Sign in first." }, { status: 401 })
  }

  const pinned = await fetchAuthQuery(api.projects.pinned, {})

  return NextResponse.json({ pinned })
}

isAuthenticated checks the session, and fetchAuthQuery runs a query as that person, so the deployment's own permissions apply and a route cannot accidentally read somebody else's rows. A route that writes uses fetchAuthMutation in the same shape.

6. Add a scheduled job

// lib/jobs.ts
export const JOBS: JobDef[] = [
  // existing jobs
  {
    id: "your-job",
    name: "Tidy something",
    hourUTC: 4,
    minuteUTC: 30,
  },
]

Nothing else is needed: convex/crons.ts iterates that list and registers every job through the same door a run asked for by hand uses. The whole list is one edit, which is also what the panel's Platform → Jobs & queue reads, so a job and its description cannot disagree.

7. Add a feature flag

Add the flag to the list in lib/flags.ts, with an id and the sentence that explains what it does.

Read it where the decision is made, on the server, and pass the answer down as a prop or a list of on-ids.

Decide the audience in Platform → Feature flags: off, on, a percentage, particular plans or named addresses.

The percentage is a hash of the flag and the account rather than a counter, so the same people get it on every request and two flags at twenty percent reach the same fifth of your accounts.

8. Change how the documentation looks

All of it is in app/globals.css, and the documentation's own block is at the end, scoped to #nd- selectors so that nothing there can reach the product or the panel.

I want to changeWhat to edit
The colours, in both themesThe :root and .dark token blocks
The radius of everything--radius, and the --radius-* steps derived from it
How the documentation's sidebar and tabs are shapedThe block at the end, under the #nd-docs-layout selectors
The typefaceThe fonts loaded in app/layout.tsx

The rhythm, in one block

# 1. change something
# 2. the gates
pnpm typecheck && pnpm lint && pnpm test

# 3. if you touched convex/
npx convex dev --once

# 4. look at it
pnpm dev

Three things that are deliberately not recipes

Adding a new dependency. Possible, and worth a moment's thought: every package added is one that has to be updated, trusted and explained in ten years. Changing the schema in a way that drops a field. Take a snapshot first, then look at how the existing migrations do it. Editing an installed package. The change belongs in this folder or it will be lost at the next install.