Skip to content
Preset · built on fullstack

SaaS kit

Start a logged-in product on its own API: sign-in, a guarded shell, a projects CRUD, analytics.

For: Founders and teams starting a signed-in product: accounts, a guarded app and a first resource to build on.

kitr new my-app --kit saas

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

kitr dev
The app and its API; sign in as [email protected] with the password password
kitr build
Both platforms; production needs SESSION_SECRET and a mail transport
npm test
Vitest, starting with the API test

Accounts that work

Sign-in, sign-up, email verification and password reset, on HttpOnly cookie sessions with CSRF protection.

A guarded app shell

Every page under the _app layout needs a session; abilities decide what each role sees.

A real resource

Projects: a data table, a create form and an API, sharing one schema in app/validators/projects.ts.

Production pieces

Mail, rate limits and security headers, analytics and error monitoring are already configured.

What's inside

Read from the kit's output in the kitr repository, so it matches what kitr new writes today. 20 packages · 14 components · 73 files.

Platforms
site: webssrapi: server
Packages
routerloggerqueryhttpformstablestatetailwindthemetoaststoragetestingseoapishieldmailauthanalyticsmonitoringvite
Components
app-shellbadgebuttoncarddata-tabledialogempty-stateformiconinputlabelpage-headerskeletontheme-toggle

Every file it writes

Start with app/pages/projects.tsx. Pick any file to read it.

Source
app/pages/projects.tsx
import { useAnalytics } from '@kitr/analytics/react'
import { useSchemaForm } from '@kitr/forms'
import { useMutation, useSuspenseQuery } from '@kitr/query'
import { getRouteApi } from '@kitr/router'
import { type ColumnDef, type DataTableFeatures, useUrlTableState } from '@kitr/table'
import { useState } from 'react'
import type { AppEvents } from '#app/analytics'
import { createProject, deleteProject, projectsQuery } from '#app/queries/projects'
import { type Project, createProjectSchema } from '#app/validators/projects'
import { Badge } from '#components/ui/badge'
import { Button } from '#components/ui/button'
import { DataTable } from '#components/ui/data-table'
import {
  Dialog,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '#components/ui/dialog'
import { FieldInput, Form, FormField, FormMessage, SubmitButton } from '#components/ui/form'
import { PageHeader } from '#components/ui/page-header'

const route = getRouteApi('/_app/projects')

export default function ProjectsPage() {
  const search = route.useSearch()
  const navigate = route.useNavigate()
  const tableState = useUrlTableState({
    search,
    setSearch: (patch) => navigate({ search: (prev: typeof search) => ({ ...prev, ...patch }) }),
  })
  const { data } = useSuspenseQuery(projectsQuery.options({}))
  const remove = useMutation(deleteProject)
  const [open, setOpen] = useState(false)

  const columns: ColumnDef<DataTableFeatures, Project>[] = [
    { accessorKey: 'name', header: 'Name' },
    {
      accessorKey: 'status',
      header: 'Status',
      cell: ({ row }) => (
        <Badge variant={row.original.status === 'active' ? 'default' : 'secondary'}>
          {row.original.status}
        </Badge>
      ),
    },
    { accessorKey: 'owner', header: 'Owner' },
    {
      accessorKey: 'updatedAt',
      header: 'Updated',
      cell: ({ row }) => new Date(row.original.updatedAt).toLocaleDateString(),
    },
    {
      id: 'actions',
      header: '',
      enableSorting: false,
      cell: ({ row }) => (
        <Button
          variant="ghost"
          size="sm"
          disabled={remove.isPending}
          onClick={() => remove.mutate({ params: { id: row.original.id } })}
        >
          Delete
        </Button>
      ),
    },
  ]

  return (
    <>
      <PageHeader
        title="Projects"
        description="Sorting and paging live in the URL, so links are shareable."
        actions={<Button onClick={() => setOpen(true)}>New project</Button>}
      />
      <DataTable columns={columns} data={data} clientSide {...tableState} />
      <NewProjectDialog open={open} onOpenChange={setOpen} />
    </>
  )
}

function NewProjectDialog({
  open,
  onOpenChange,
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
}) {
  const create = useMutation(createProject)
  const analytics = useAnalytics<AppEvents>()
  const form = useSchemaForm(createProjectSchema, { defaultValues: { name: '', status: 'active' } })
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogHeader>
        <DialogTitle>New project</DialogTitle>
        <DialogDescription>Validated by the same schema the API uses.</DialogDescription>
      </DialogHeader>
      <Form
        form={form}
        className="grid gap-4"
        resetOnSuccess
        onSubmit={async (values) => {
          const project = await create.mutateAsync({ body: values })
          analytics.track('project.created', { id: project.id })
          onOpenChange(false)
        }}
      >
        <FormMessage />
        <FormField name="name" label="Name">
          <FieldInput autoFocus placeholder="Q3 launch" />
        </FormField>
        <DialogFooter>
          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
            Cancel
          </Button>
          <SubmitButton pendingText="Creating…">Create</SubmitButton>
        </DialogFooter>
      </Form>
    </Dialog>
  )
}

Other kits

All kits →