С любовью ко всем разработчикам на свете ❤️
Start0 — это основа, на которой я делаю собственные продукты, включая этот сайт. Это работающее приложение с первой минуты: зарегистрируйтесь, управляйте пользователями в админке, отправляйте письма, запускайте тесты, деплойте.
Он закрытый и платный: код идёт вместе с Support Pack — $10 в месяц или $100 в год — и подписка финансирует мою опенсорс-работу над Point0 и библиотеками.
Start0 не является частью Point0 — сам фреймворк бесплатный и опенсорсный. Если нужен чистый проект на Point0, об этом есть Getting Started: команда создания и примеры. Start0 — это готовая к продакшену реализация Point0 с уже подключённой обвязкой.
Я буду разбирать Point0 и все интеграции вокруг него в бесплатных статьях и видео — на это нужно время. В Start0 всё это уже собрано и работает: пользуйтесь сегодня, вместо того чтобы ждать или собирать по кускам самому.
Честное замечание: Point0 и библиотеки на раннем этапе, баги возможны. Я реагирую и чиню быстро — на Start0 работают мои собственные продукты, включая этот сайт, так что фиксы прилетают быстро и доходят до вас обновлениями. Спасибо за понимание.
bun create start0@latest my-appОна авторизует вас, проверяет вашу подписку Support Pack, скачивает последний релиз и запускает init-скрипт: имя вашего приложения везде и свежий .env со сгенерированным ключом авторизации.
cd my-app
bun install
# fill DATABASE_URL and ADMIN_CREDENTIALS in .env
bun prisma:migrate
bun seed
bun devОбновление — это не мерж. Одна команда строит дифф Start0 с самим собой — от версии, с которой вы начали, до последней — и печатает промпт для вашего кодинг-агента:
bunx 1gr14@latest updateАгент применяет то, что уместно, адаптирует переименованное и пропускает то, что вы удалили намеренно. Записи чейнджлога с заметками по миграции — и подъём маркера версии — едут внутри диффа. Пока подписка активна, каждый новый релиз ваш.
AGENTS.md — это инструкция: что где лежит, что прочитать перед задачей, как проверить результат. Конвенции живут в docs/ и в JSDoc рядом с кодом, связанные через @related, чтобы агент шёл по графу, а не угадывал. Ещё Point0 поставляется с MCP-сервером документации, так что агент читает доки фреймворка прямо в вашем редакторе.
Половину работы делает структура: src/modules для переиспользуемой механики, src/features для продуктового кода, src/lib для маленьких кирпичиков. Новая фича начинается как копия соседней — и для вас, и для агента.
Guide0 — это одна единица контента: объяснение человеческим языком, что это, зачем и как этим пользоваться, плюс проверенный рабочий код, который аккуратно встраивается в ваше приложение, даже сильно переделанное. Один и тот же гайд работает и для вас, и для вашего кодинг-агента. Это не генератор — гайды следуют структуре основы, поэтому они подходят.
Гайды позволят заменить то, что основа решила за вас, и добавить то, чего в ней нет: заменить Stripe на DodoPayments, Lemon Squeezy или другого провайдера; задеплоить не на Railway, а на другую платформу или собственный сервер; и подключать новые возможности вплоть до целых фич.
Support Pack — это одна подписка, которая финансирует опенсорс-работу и возвращает бонусы: Start0 с обновлениями плюс всё остальное, что перечислено на странице подписки. Подпишитесь и нажмите Get Code — там три пути: CLI bun create start0, ZIP последнего релиза или инвайт в приватный GitHub-репозиторий.
Ideas — фича, которая идёт в комплекте: небольшой CRUD с бесконечным скроллом, публичные профили авторов, формы создания и редактирования. Она сделана так же, как вы делали бы свою: типизированное API из запросов и мутаций, страницы, которые префетчат свои данные, формы, связанные со схемами Zod. Прочитайте её, чтобы почувствовать, как выглядит настоящая разработка на Point0 внутри Start0, — а потом скопируйте папку и сделайте её своей.
// src/features/idea/api.ts
import { paginateCursor } from '@/components/blocks/pagination'
import { AppError } from '@/lib/error'
import { root } from '@/lib/root'
import { zz } from '@/lib/schema'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
import { prisma } from '@/modules/prisma'
import { ideaSelect, normalizeIdeaPayload } from '@/features/idea/server'
import { z } from 'zod'
export const ideaListQuery = root.lets
.infiniteQuery()
.input(
z.object({
...zz.shape.paginationCursor,
authorSn: zz.sn.optional(),
}),
)
.loader(async ({ input: { limit = 20, cursor, authorSn } }) => {
const items = await prisma.idea.findMany({
select: ideaSelect,
orderBy: { sn: 'desc' },
take: limit + 1,
where: {
...(authorSn ? { author: { sn: authorSn } } : {}),
...(cursor ? { sn: { lte: cursor } } : {}),
},
})
return paginateCursor({
items: items.map(normalizeIdeaPayload),
limit,
cursorKey: 'sn',
})
})
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
initialPageParam: undefined,
pageParamFromInput: 'cursor',
})
export const ideaViewQuery = root.lets
.query()
.input(zz.object.sn)
.loader(async ({ input: { sn } }) => {
const idea = await prisma.idea.findUniqueOrThrow({
select: ideaSelect,
where: { sn },
})
return { idea: normalizeIdeaPayload(idea) }
})
.query()
export const ideaCreateMutationSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
})
export const ideaCreateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin)
.input(ideaCreateMutationSchema)
.loader(async ({ ctx, input: { title, content } }) => {
const idea = await prisma.idea.create({
select: ideaSelect,
data: { title, content, authorId: ctx.me.user.id },
})
return { idea: normalizeIdeaPayload(idea) }
})
.mutation()
export const ideaUpdateMutationSchema = z.object({
sn: zz.sn,
title: z.string().min(1),
content: z.string().min(1),
})
export const ideaUpdateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin)
.input(ideaUpdateMutationSchema)
.loader(async ({ ctx, input: { sn, title, content } }) => {
const existing = await prisma.idea.findUniqueOrThrow({
select: { authorId: true },
where: { sn },
})
if (existing.authorId !== ctx.me.user.id) {
throw new AppError('Only the author can edit this idea', {
code: 'FORBIDDEN',
})
}
const idea = await prisma.idea.update({
select: ideaSelect,
where: { sn },
data: { title, content },
})
return { idea: normalizeIdeaPayload(idea) }
})
.mutation()// src/features/idea/pages/list.tsx
import { InfiniteScroll } from '@/components/blocks/infinite-scroll'
import { Section } from '@/components/ui/section'
import { generalLayout } from '@/layouts/general'
import { IdeaCard } from '@/features/idea/components/idea-card'
import { ideaListQuery } from '@/features/idea/api'
import { mePlugin } from '@/modules/auth/plugins'
export const ideaListPage = generalLayout.lets
.page('/ideas')
.head('Ideas')
.use(mePlugin)
.page(({ props: { me } }) => {
const query = ideaListQuery.useInfiniteQuery()
return (
<Section h1="Ideas">
<InfiniteScroll
query={query}
loadMoreOnReachEnd
getItemKey={(idea) => idea.sn}
empty="No ideas yet. Be the first to share one."
itemClassName="border-b border-border last:border-b-0"
renderItem={(idea) => <IdeaCard idea={idea} me={me} />}
/>
</Section>
)
})// src/features/idea/pages/view.tsx
import { Button } from '@/components/ui/button'
import { Prose } from '@/components/ui/prose'
import { Section } from '@/components/ui/section'
import { routes } from '@/generated/point0/routes'
import { Link } from '@/lib/navigation'
import { zz } from '@/lib/schema'
import { formatDate } from '@/utils/date'
import { generalLayout } from '@/layouts/general'
import { ideaViewQuery } from '@/features/idea/api'
import { isMyIdea } from '@/features/idea/shared'
import { mePlugin } from '@/modules/auth/plugins'
export const ideaViewPage = generalLayout.lets
.page('/ideas/:sn')
.params(zz.object.sn)
.use(mePlugin)
.with(ideaViewQuery, ({ params }) => ({ sn: +params.sn }))
.head(({ params }) => `Idea #${params.sn}`)
.page(({ data: { idea }, props: { me } }) => {
return (
<Section
h1={idea.title}
action={
isMyIdea(idea, me) ? (
<Button
to={routes.ideaEdit({ sn: idea.sn })}
variant="outline-secondary"
>
Edit idea
</Button>
) : undefined
}
description={
<span className="flex flex-wrap items-center gap-2">
<Link
to={routes.userProfile({ sn: idea.author.sn })}
className="hover:text-foreground"
>
{idea.author.name}
</Link>
<span>·</span>
<span>{formatDate(idea.createdAt, 'date-time-nice')}</span>
{idea.updatedAt > idea.createdAt ? (
<span>· edited {formatDate(idea.updatedAt, 'date-nice')}</span>
) : null}
</span>
}
>
<Prose>
<p className="whitespace-pre-wrap">{idea.content}</p>
</Prose>
</Section>
)
})// src/features/idea/pages/new.tsx
import { Section } from '@/components/ui/section'
import { routes } from '@/generated/point0/routes'
import { navigate } from '@/lib/navigation'
import { generalLayout } from '@/layouts/general'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
import { FButton } from '@/modules/form/core/button'
import { FFields, FFooter } from '@/modules/form/core/layout'
import { FForm } from '@/modules/form/core/provider'
import { FInput } from '@/modules/form/fields/input'
import { FTextarea } from '@/modules/form/fields/textarea'
import {
ideaCreateMutation,
ideaCreateMutationSchema,
ideaViewQuery,
ideaListQuery,
} from '@/features/idea/api'
export const ideaNewPage = generalLayout.lets
.page('/ideas/new')
.head('New Idea')
.use(authorizedOnlyPlugin)
.page(() => {
return (
<Section h1="New Idea">
<FForm
schema={ideaCreateMutationSchema}
defaultValues={{ title: '', content: '' }}
onSubmit={async ({ title, content }) => {
const { idea } = await ideaCreateMutation.fetch({ title, content })
void ideaListQuery.invalidateInfiniteQuery(true)
ideaViewQuery.setQueryData({ sn: idea.sn }, { idea })
return { idea }
}}
onSuccess={({ idea }) => {
void navigate('ideaView', { sn: idea.sn }, { replace: true })
}}
toastOnSuccess="Idea published"
size="sm"
>
<FFields>
<FInput
name="title"
label="Title"
placeholder="A short, catchy title"
inputSize="xl"
/>
<FTextarea
name="content"
label="Content"
placeholder="Share your idea…"
rows={10}
/>
</FFields>
<FFooter>
<FButton type="submit" size="2xl">
Publish
</FButton>
</FFooter>
</FForm>
</Section>
)
})// src/features/idea/pages/edit.tsx
import { Button } from '@/components/ui/button'
import { Section } from '@/components/ui/section'
import {
ideaListQuery,
ideaUpdateMutation,
ideaUpdateMutationSchema,
ideaViewQuery,
} from '@/features/idea/api'
import { routes } from '@/generated/point0/routes'
import { generalLayout } from '@/layouts/general'
import { navigate } from '@/lib/navigation'
import { zz } from '@/lib/schema'
import { FButton } from '@/modules/form/core/button'
import { FFields, FFooter } from '@/modules/form/core/layout'
import { FForm } from '@/modules/form/core/provider'
import { FInput } from '@/modules/form/fields/input'
import { FTextarea } from '@/modules/form/fields/textarea'
export const ideaEditPage = generalLayout.lets
.page('/ideas/:sn/edit')
.params(zz.object.sn)
.with(ideaViewQuery, ({ params }) => ({ sn: +params.sn }))
.head(({ params }) => `Edit Idea #${params.sn}`)
.page(({ data: { idea } }) => {
return (
<Section
h1="Edit Idea"
action={
<Button
to={routes.ideaView({ sn: idea.sn })}
variant="outline-secondary"
>
View idea
</Button>
}
>
<FForm
schema={ideaUpdateMutationSchema.pick({ title: true, content: true })}
defaultValues={{ title: idea.title, content: idea.content }}
onSubmit={async ({ title, content }) => {
const { idea: updated } = await ideaUpdateMutation.fetch({
sn: idea.sn,
title,
content,
})
ideaViewQuery.setQueryData({ sn: updated.sn }, { idea: updated })
void ideaListQuery.invalidateInfiniteQuery(true)
return { idea: updated }
}}
onSuccess={({ idea }) => {
void navigate('ideaView', { sn: idea.sn }, { replace: true })
}}
toastOnSuccess="Idea updated"
size="sm"
>
<FFields>
<FInput name="title" label="Title" inputSize="xl" />
<FTextarea name="content" label="Content" rows={10} />
</FFields>
<FFooter>
<FButton type="submit" size="2xl">
Save
</FButton>
</FFooter>
</FForm>
</Section>
)
})// src/features/idea/pages/my.tsx
import { InfiniteScroll } from '@/components/blocks/infinite-scroll'
import { Section } from '@/components/ui/section'
import { ideaListQuery } from '@/features/idea/api'
import { IdeaCard } from '@/features/idea/components/idea-card'
import { generalLayout } from '@/layouts/general'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
export const myIdeaListPage = generalLayout.lets
.page('/my/ideas')
.head('My Ideas')
.use(authorizedOnlyPlugin)
.with(ideaListQuery, ({ props: { me } }) => ({ authorSn: me.user.sn }))
.page(({ queries: [query], props: { me } }) => {
return (
<Section h1="My Ideas">
<InfiniteScroll
query={query}
loadMoreOnReachEnd
getItemKey={(idea) => idea.sn}
empty="You haven't shared any ideas yet."
itemClassName="border-b border-border last:border-b-0"
renderItem={(idea) => <IdeaCard idea={idea} me={me} />}
/>
</Section>
)
})Support Pack — это одна подписка, которая финансирует опенсорс-работу и возвращает бонусы: Start0 с обновлениями плюс всё остальное, что перечислено на странице подписки. Подпишитесь и нажмите Get Code — там три пути: CLI bun create start0, ZIP последнего релиза или инвайт в приватный GitHub-репозиторий.
Start0 распространяется как source-available по лицензии из Terms of Service: используйте код, чтобы строить свои продукты, в том числе коммерческие. То, что вы построили, — ваше и остаётся вашим после окончания подписки; заканчивается доступ к репозиторию и будущие обновления. Нельзя перепродавать или распространять сам бойлерплейт и делать из него конкурирующий стартер. Код поставляется как есть, без гарантий, и короткий LICENSE.md едет в каждой копии.