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 saasFirst 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_SECRETand 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.
Every file it writes
Start with app/pages/projects.tsx. Pick any file to read it.
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>
)
}