Tutorial/tutorials

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 useForm and display validation errors
  • Why bun run codegen exists 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:

  • PostRecord is inferred from the table — you never write the record type by hand.
  • fillable is a mass-assignment allowlist: passing any field outside it to Post.create() or Post.update() throws a MassAssignmentException, 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:

  • index validates ?page= with validateQuery, fetches a page of posts, and wraps the result with the paginate helper, which builds the page links your React component renders.
  • show validates the :id route param and uses findOrFail, which automatically responds with 404 when the post does not exist — no manual null check.
  • create just renders the form page.
  • store validates the request body with validateBody. On failure it throws a 422 with field-level messages, and Inertia feeds those back into your form as form.errors — you write no error-handling code for that.
  • pages.posts.Index comes 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 a name option) so pages can link with the typed route() helper instead of hard-coded URLs.
  • The body: PostPayloadSchema option attaches the Zod schema to the route contract, so codegen can generate a typed request body for the frontend.
  • /create is registered before /:id. Routes match top to bottom — if /:id came first, /posts/create would 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">
        &larr; 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">
        &larr; 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):

  1. Open http://localhost:3333/posts — you see the empty state ("No posts yet").
  2. 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.
  3. Fill in a title and body and submit — you are redirected to the new post's page.
  4. 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.