Part 1: Create a Blog Post App
In this part you build the heart of the blog: creating, listing, and reading posts. You write every file by hand — table, model, validator, controller, routes, and pages — so you see exactly how a request travels through a Guren application.
Part 1: Create a Blog Post App
In this part you build the heart of the blog: creating, listing, and reading posts. You write every file by hand — table, model, validator, controller, routes, and pages — so you see exactly how a request travels through a Guren application.
What you'll learn:
- How to scaffold a new Guren app with SQLite (zero configuration)
- How the pieces connect: schema → model → validator → controller → routes → Inertia pages
- How to validate requests with Zod and
validateBody/validateQuery/validateParams - How to build forms with Inertia's
useFormand display validation errors - Why
bun run codegenexists and when to re-run it
Tip
Everything in this part can be generated with a single command: bunx guren add resource posts --fields "title:string,body:text". We build it manually so you understand what that generator produces — after this tutorial, you can decide when to reach for the shortcut.
1. Scaffold the app
Create a new project and answer the prompts:
bunx create-guren-app my-blog
The CLI asks two questions:
- Rendering mode — keep the default (SSR).
- Database driver — keep the default (SQLite, "zero-config, recommended for getting started").
The scaffolder copies the template, writes a ready-to-use .env (including a generated APP_KEY and DATABASE_URL=./data/guren.db), and installs dependencies for you. Then start the dev server:
cd my-blog
bun run dev
Checkpoint: open http://localhost:3333. You should see the welcome page — a "Welcome to My Blog!" heading, a grid of feature cards, and a "Next steps" box with suggested commands. No database setup, no containers: the SQLite file is created on demand under ./data/.
Leave the dev server running in this terminal and use a second one for the commands below.
2. Define the posts table
The scaffold uses SQLite, so tables are declared with sqliteTable and the SQLite column helpers. Add a posts table to your schema.
Edit db/schema.ts and add the highlighted table below the existing users table:
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull(),
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
})
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
body: text('body').notNull(),
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
})
Generate a migration from the schema, then apply it:
bun run db:make create_posts_table
bun run db:migrate
db:make diffs db/schema.ts against your existing migrations and writes a new SQL file into db/migrations/. db:migrate applies it to the SQLite database.
3. Create the Post model
Models give you a Laravel-style API (find, create, paginate, relationships) on top of your Drizzle tables.
Create app/Models/Post.ts:
import { defineModel } from '@guren/core'
import { posts } from '../../db/schema.js'
export type PostRecord = typeof posts.$inferSelect
export type NewPostRecord = typeof posts.$inferInsert
export class Post extends defineModel(posts) {
static fillable = ['title', 'body']
}
Two things to note:
PostRecordis inferred from the table — you never write the record type by hand.fillableis a mass-assignment allowlist: passing any field outside it toPost.create()orPost.update()throws aMassAssignmentException, so bugs and injection attempts surface immediately instead of being silently discarded. For trusted server-side data (seeders, system records),forceCreate()bypasses the allowlist — see the Database guide for details.
4. Add a validator
Keep your Zod schemas in one file per resource so controllers and routes can share them.
Create app/Http/Validators/PostValidator.ts:
import { z } from 'zod'
export const PostPayloadSchema = z.object({
title: z.string().trim().min(1, 'Title is required.'),
body: z.string().trim().min(1, 'Body is required.'),
})
export type PostPayload = z.infer<typeof PostPayloadSchema>
export const PostIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
})
export const ListPostsQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
})
Route params and query strings arrive as strings, so z.coerce.number() converts them before validating.
5. Build the controller
Create app/Http/Controllers/PostController.ts:
import { Controller, paginate, type PaginatedPageProps } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
import { Post, type PostRecord } from '../../Models/Post.js'
import {
ListPostsQuerySchema,
PostIdParamSchema,
PostPayloadSchema,
} from '../Validators/PostValidator.js'
type PostsIndexProps = PaginatedPageProps<PostRecord>
export default class PostController extends Controller {
async index(): Promise<Response> {
const { page } = this.validateQuery(ListPostsQuerySchema)
const result = await Post.paginate({ page, perPage: 10, orderBy: ['id', 'desc'] })
const paginator = paginate(result, { path: this.request.path ?? '/posts' })
return this.inertia(pages.posts.Index, {
data: result.data,
pagination: {
meta: paginator.meta(),
links: paginator.links(),
},
} satisfies PostsIndexProps)
}
async show(): Promise<Response> {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findOrFail(id)
return this.inertia(pages.posts.Show, { post })
}
async create(): Promise<Response> {
return this.inertia(pages.posts.Create, {})
}
async store(): Promise<Response> {
const data = await this.validateBody(PostPayloadSchema)
const post = await Post.create(data)
return this.redirect(post ? `/posts/${post.id}` : '/posts')
}
}
How each action works:
indexvalidates?page=withvalidateQuery, fetches a page of posts, and wraps the result with thepaginatehelper, which builds the page links your React component renders.showvalidates the:idroute param and usesfindOrFail, which automatically responds with 404 when the post does not exist — no manual null check.createjust renders the form page.storevalidates the request body withvalidateBody. On failure it throws a 422 with field-level messages, and Inertia feeds those back into your form asform.errors— you write no error-handling code for that.pages.posts.Indexcomes from.guren/pages.gen.ts, a generated manifest that ties page names to their prop types. Your editor will complain until you run codegen in step 8 — that's expected.
6. Register the routes
Edit routes/web.ts:
import { Router } from '@guren/core'
import HomeController from '../app/Http/Controllers/HomeController.js'
import PostController from '../app/Http/Controllers/PostController.js'
import { PostPayloadSchema } from '../app/Http/Validators/PostValidator.js'
export function registerWebRoutes(router: Router): void {
router.get('/', [HomeController, 'index'])
// Health check endpoint for load balancers and uptime monitors
router.get('/health', (c) => c.json({ status: 'ok' }))
router.group('/posts', (posts) => {
posts.get('/', [PostController, 'index']).name('posts.index')
posts.get('/create', [PostController, 'create']).name('posts.create')
posts.get('/:id', [PostController, 'show']).name('posts.show')
posts.post('/', { name: 'posts.store', body: PostPayloadSchema }, [PostController, 'store'])
})
}
Details worth noticing:
- Every route gets a
.name()(or anameoption) so pages can link with the typedroute()helper instead of hard-coded URLs. - The
body: PostPayloadSchemaoption attaches the Zod schema to the route contract, so codegen can generate a typed request body for the frontend. /createis registered before/:id. Routes match top to bottom — if/:idcame first,/posts/createwould try to parse"create"as an id.
7. Create the pages
Inertia pages are plain React components under resources/js/pages/. The directory path becomes the page name: resources/js/pages/posts/Index.tsx is pages.posts.Index. Declaring an interface Props lets codegen extract the prop types so this.inertia() is type-checked end to end.
Create resources/js/pages/posts/Index.tsx:
import { Link } from '@inertiajs/react'
import type { PaginatedPageProps } from '@guren/core'
import type { PostRecord } from '@/app/Models/Post'
import { route } from '@/.guren/routes.gen'
interface Props extends PaginatedPageProps<PostRecord> {}
export default function PostsIndex({ data: posts, pagination }: Props) {
return (
<main className="mx-auto max-w-3xl space-y-6 px-6 py-12">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold">Posts</h1>
<Link href={route('posts.create')} className="rounded bg-black px-4 py-2 text-white">
New post
</Link>
</div>
{posts.length === 0 ? (
<p className="text-zinc-500">No posts yet. Write the first one!</p>
) : (
<div className="space-y-4">
{posts.map((post) => (
<article key={post.id} className="rounded border p-4">
<Link
href={route('posts.show', { id: post.id })}
className="text-xl font-medium hover:underline"
>
{post.title}
</Link>
<p className="mt-1 text-sm text-zinc-500">
{new Date(post.createdAt).toLocaleDateString()}
</p>
</article>
))}
</div>
)}
{pagination.meta.lastPage > 1 && (
<nav className="flex gap-2" aria-label="Pagination">
{pagination.links.pages.map((page) => (
<Link
key={page.page}
href={page.url ?? '#'}
className={`rounded border px-3 py-1 ${page.active ? 'bg-black text-white' : ''}`}
>
{page.page}
</Link>
))}
</nav>
)}
</main>
)
}
The import type { PostRecord } line pulls a type from server-side code — that's fine, because type-only imports are erased at build time.
Create resources/js/pages/posts/Show.tsx:
import { Link } from '@inertiajs/react'
import type { PostRecord } from '@/app/Models/Post'
import { route } from '@/.guren/routes.gen'
interface Props {
post: PostRecord
}
export default function PostsShow({ post }: Props) {
return (
<main className="mx-auto max-w-3xl space-y-6 px-6 py-12">
<Link href={route('posts.index')} className="text-sm text-zinc-500 hover:underline">
← Back to posts
</Link>
<h1 className="text-3xl font-semibold">{post.title}</h1>
<p className="text-sm text-zinc-500">{new Date(post.createdAt).toLocaleDateString()}</p>
<div className="space-y-4 leading-relaxed">
{post.body.split('\n').map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
</main>
)
}
Create resources/js/pages/posts/Create.tsx:
import { Link, useForm } from '@inertiajs/react'
import { route } from '@/.guren/routes.gen'
type PostFormData = {
title: string
body: string
}
export default function PostsCreate() {
const form = useForm<PostFormData>({ title: '', body: '' })
return (
<main className="mx-auto max-w-3xl space-y-6 px-6 py-12">
<Link href={route('posts.index')} className="text-sm text-zinc-500 hover:underline">
← Back to posts
</Link>
<h1 className="text-3xl font-semibold">New post</h1>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
form.post(route('posts.store'))
}}
>
<div>
<label htmlFor="title" className="block text-sm font-medium">
Title
</label>
<input
id="title"
value={form.data.title}
onChange={(event) => form.setData('title', event.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
{form.errors.title && <p className="mt-1 text-sm text-red-600">{form.errors.title}</p>}
</div>
<div>
<label htmlFor="body" className="block text-sm font-medium">
Body
</label>
<textarea
id="body"
rows={8}
value={form.data.body}
onChange={(event) => form.setData('body', event.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
{form.errors.body && <p className="mt-1 text-sm text-red-600">{form.errors.body}</p>}
</div>
<button
type="submit"
disabled={form.processing}
className="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
>
Create post
</button>
</form>
</main>
)
}
useForm handles the whole submit lifecycle: form.post() sends the data, form.processing disables the button in flight, and when the server rejects with a 422, form.errors.title / form.errors.body hold the messages from your Zod schema.
8. Run codegen
bun run codegen
Why: codegen scans your routes and pages and writes typed manifests into .guren/ — pages.gen.ts (page names plus the Props extracted from each component, used by this.inertia()) and routes.gen.ts (the route() helper with autocomplete for route names and params). This is what makes a typo like pages.posts.Idnex or a missing prop a compile-time error instead of a runtime surprise.
When to re-run: whenever you add, rename, or remove a route or page, or change a page's Props. In practice you rarely run it by hand — bun run dev runs codegen on startup, and the dev server watches routes/web.ts and resources/js/pages/ and regenerates on change. Reach for the explicit command when your editor shows stale types.
9. Checkpoint: create your first post
With the dev server running (bun run dev if you stopped it):
- Open http://localhost:3333/posts — you see the empty state ("No posts yet").
- Click New post, submit the form with both fields empty — the page stays put and shows "Title is required." under the input. That's your Zod schema talking, round-tripped through the 422 response into
form.errors. - Fill in a title and body and submit — you are redirected to the new post's page.
- Go back to
/posts— your post is in the list.
You built a complete request cycle: route → validation → model → database → Inertia page, all type-checked.
Common problems
Cannot find module '.guren/pages.gen' or pages.posts.Index does not exist.
Codegen hasn't run since you added the pages. Run bun run codegen (or restart bun run dev).
no such table: posts when opening /posts.
The migration was never generated or applied. Run bun run db:make create_posts_table and then bun run db:migrate.
Submitting the form does nothing.
It almost certainly returned a 422. Check that your page renders form.errors.title and form.errors.body — the messages are there, you're just not showing them. The network tab (an X-Inertia POST to /posts) confirms it.
/posts/create responds with 404 or a validation error about id.
Your /:id route is registered before /create. Declare posts.get('/create', ...) above posts.get('/:id', ...).
Type error in this.inertia(pages.posts.Index, ...) after changing props.
The page manifest is stale. Re-run bun run codegen so the extracted Props match what the controller sends.
Directory "my-blog" is not empty when scaffolding.
Pick a fresh directory name or pass --force to scaffold anyway.
Next
Right now anyone can publish a post. Fix that in Part 2: Add Authentication.