Skip to main content

Overview

The AdonisJS Starter Kit is designed to be a solid foundation for your application while remaining flexible and extensible. This guide covers best practices for customizing and extending the kit without breaking its core functionality.

Architecture Overview

The starter kit uses a modular architecture powered by @adonisjs-community/modules:
apps/web/app/
├── analytics/     # Analytics module
├── auth/          # Authentication module
├── common/        # Shared utilities
├── core/          # Core functionality
├── marketing/     # Marketing pages
└── users/         # User management module
Each module is self-contained with its own controllers, models, policies, routes, and UI components.

Creating New Modules

Generate a Module

Create a new feature module using the AdonisJS CLI:
node ace make:module blog
This creates a new module structure:
app/blog/
├── controllers/
├── models/
├── policies/
├── services/
├── validators/
├── routes.ts
└── start/

Generate Module Resources

Generate resources scoped to your module:
# Generate a controller
node ace make:controller posts -m=blog

# Generate a model
node ace make:model post -m=blog

# Generate a policy
node ace make:policy post_policy -m=blog

# Generate a validator
node ace make:validator create_post -m=blog
The -m=blog flag ensures files are generated inside the blog module.

Adding Custom Models

Extend BaseModel

All models should extend the BaseModel for consistent timestamps and serialization:
app/blog/models/post.ts
import { column, belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import BaseModel from '#common/models/base_model'
import User from '#users/models/user'

export default class Post extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare authorId: number

  @column()
  declare title: string

  @column()
  declare content: string

  @column()
  declare publishedAt: DateTime | null

  @belongsTo(() => User, {
    foreignKey: 'authorId',
  })
  declare author: BelongsTo<typeof User>
}

Add File Attachments

Extend models with file attachment support:
import { attachment } from '@jrmc/adonis-attachment'
import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'

export default class Post extends BaseModel {
  // ... other columns

  @attachment({ preComputeUrl: false, variants: ['thumbnail', 'large'] })
  declare featuredImage: Attachment
}

Creating Custom Policies

Define Resource Policies

Create authorization policies following the starter kit’s pattern:
app/blog/policies/post_policy.ts
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
import User from '#users/models/user'
import Post from '#blog/models/post'

export default class PostPolicy extends BasePolicy {
  viewList(user: User): AuthorizerResponse {
    return true // Everyone can view post list
  }

  view(user: User, post: Post): AuthorizerResponse {
    // Can view if published or if author/admin
    return (
      post.publishedAt !== null ||
      user.id === post.authorId ||
      user.isAdmin
    )
  }

  create(user: User): AuthorizerResponse {
    return true // Authenticated users can create posts
  }

  update(user: User, post: Post): AuthorizerResponse {
    return user.id === post.authorId || user.isAdmin
  }

  delete(user: User, post: Post): AuthorizerResponse {
    return user.id === post.authorId || user.isAdmin
  }

  publish(user: User, post: Post): AuthorizerResponse {
    // Only author and admins can publish
    return user.id === post.authorId || user.isAdmin
  }
}

Register the Policy

Add your policy to the main policies configuration:
app/core/policies/main.ts
export const policies = {
  // Existing policies
  UserPolicy: () => import('#users/policies/user_policy'),
  ImpersonatePolicy: () => import('#users/policies/impersonate_policy'),
  TokenPolicy: () => import('#users/policies/token_policy'),
  
  // Your new policies
  PostPolicy: () => import('#blog/policies/post_policy'),
}

Adding Custom Routes

Module Routes

Define routes in your module’s routes.ts:
app/blog/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

const PostsController = () => import('#blog/controllers/posts_controller')
const CommentsController = () => import('#blog/controllers/comments_controller')

// Public routes
router.get('/blog', [PostsController, 'index']).as('blog.index')
router.get('/blog/:slug', [PostsController, 'show']).as('blog.show')

// Protected routes
router
  .group(() => {
    router
      .resource('/blog/posts', PostsController)
      .except(['index', 'show'])
      .as('blog.posts')
    
    router.post('/blog/posts/:id/publish', [PostsController, 'publish'])
    router.resource('/blog/posts/:postId/comments', CommentsController)
  })
  .use(middleware.auth())

Register Module Routes

Register your module routes in adonisrc.ts:
adonisrc.ts
export default defineConfig({
  // ... other config
  
  preloads: [
    () => import('#start/kernel'),
    
    // Existing modules
    () => import('#marketing/routes'),
    () => import('#auth/start/view'),
    () => import('#auth/start/events'),
    () => import('#auth/routes'),
    () => import('#users/start/view'),
    () => import('#users/start/events'),
    () => import('#users/routes'),
    () => import('#analytics/routes'),
    
    // Your new module
    () => import('#blog/routes'),
  ],
})

Creating Custom Controllers

Follow the Pattern

Implement controllers following the starter kit’s conventions:
app/blog/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#blog/models/post'
import PostPolicy from '#blog/policies/post_policy'
import { createPostValidator, updatePostValidator } from '#blog/validators'

export default class PostsController {
  async index({ inertia, request }: HttpContext) {
    const page = request.input('page', 1)
    const posts = await Post.query()
      .whereNotNull('published_at')
      .preload('author')
      .orderBy('published_at', 'desc')
      .paginate(page, 10)

    return inertia.render('blog/index', { posts })
  }

  async show({ inertia, params, bouncer }: HttpContext) {
    const post = await Post.query()
      .where('slug', params.slug)
      .preload('author')
      .firstOrFail()

    await bouncer.with(PostPolicy).authorize('view', post)

    return inertia.render('blog/show', { post })
  }

  async store({ request, response, auth, bouncer }: HttpContext) {
    await bouncer.with(PostPolicy).authorize('create')

    const payload = await request.validateUsing(createPostValidator)
    const post = await Post.create({
      ...payload,
      authorId: auth.user!.id,
    })

    return response.redirect().toRoute('blog.posts.show', { id: post.id })
  }

  async update({ request, params, response, bouncer }: HttpContext) {
    const post = await Post.findOrFail(params.id)
    await bouncer.with(PostPolicy).authorize('update', post)

    const payload = await request.validateUsing(updatePostValidator)
    post.merge(payload)
    await post.save()

    return response.redirect().toRoute('blog.posts.show', { id: post.id })
  }

  async destroy({ params, response, bouncer }: HttpContext) {
    const post = await Post.findOrFail(params.id)
    await bouncer.with(PostPolicy).authorize('delete', post)

    await post.delete()

    return response.redirect().toRoute('blog.index')
  }
}

Adding Custom Validators

VineJS Validators

Create type-safe validators using VineJS:
app/blog/validators.ts
import vine from '@vinejs/vine'
import { baseSearchValidator } from '#common/validators/search'

export const createPostValidator = vine.compile(
  vine.object({
    title: vine.string().trim().minLength(3).maxLength(255),
    content: vine.string().trim().minLength(10),
    slug: vine
      .string()
      .trim()
      .toLowerCase()
      .unique({ table: 'posts', column: 'slug' }),
    featuredImage: vine
      .file({
        extnames: ['png', 'jpg', 'jpeg', 'webp'],
        size: 2 * 1024 * 1024, // 2MB
      })
      .nullable(),
  })
)

export const updatePostValidator = vine.withMetaData<{ postId: number }>().compile(
  vine.object({
    title: vine.string().trim().minLength(3).maxLength(255),
    content: vine.string().trim().minLength(10),
    slug: vine
      .string()
      .trim()
      .toLowerCase()
      .unique(async (_, value, field) => {
        const row = await Post.query()
          .where('slug', value)
          .whereNot('id', field.meta.postId)
          .first()
        return row ? false : true
      }),
  })
)

export const listPostsValidator = vine.compile(
  vine.object({
    ...baseSearchValidator.getProperties(),
    status: vine.enum(['published', 'draft', 'all']).optional(),
    authorId: vine.number().exists({ table: 'users', column: 'id' }).optional(),
  })
)

Extending the UI

Adding ShadCN Components

Install additional UI components:
pnpm dlx shadcn@latest add tabs -c apps/web
pnpm dlx shadcn@latest add separator -c apps/web
pnpm dlx shadcn@latest add textarea -c apps/web

Create Module-Specific Components

Organize React components within your module:
app/blog/
└── ui/
    ├── components/
    │   ├── post_card.tsx
    │   ├── post_editor.tsx
    │   └── comment_list.tsx
    └── pages/
        ├── index.tsx
        ├── show.tsx
        └── edit.tsx

Example Component

app/blog/ui/components/post_card.tsx
import { Link } from '@inertiajs/react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { formatDate } from '@/lib/utils'

interface PostCardProps {
  post: {
    id: number
    title: string
    excerpt: string
    publishedAt: string
    author: {
      fullName: string
    }
  }
}

export function PostCard({ post }: PostCardProps) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>
          <Link href={`/blog/${post.id}`}>{post.title}</Link>
        </CardTitle>
        <CardDescription>
          By {post.author.fullName}{formatDate(post.publishedAt)}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <p className="text-muted-foreground">{post.excerpt}</p>
      </CardContent>
    </Card>
  )
}

Adding Custom Services

Service Layer Pattern

Create service classes for complex business logic:
app/blog/services/post_service.ts
import Post from '#blog/models/post'
import User from '#users/models/user'
import { DateTime } from 'luxon'

export default class PostService {
  static async publish(post: Post): Promise<void> {
    post.publishedAt = DateTime.now()
    await post.save()
    
    // Send notifications, update search index, etc.
    await this.notifySubscribers(post)
    await this.updateSearchIndex(post)
  }

  static async unpublish(post: Post): Promise<void> {
    post.publishedAt = null
    await post.save()
    
    await this.removeFromSearchIndex(post)
  }

  static async generateSlug(title: string): Promise<string> {
    const baseSlug = title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-|-$/g, '')

    let slug = baseSlug
    let counter = 1

    while (await Post.findBy('slug', slug)) {
      slug = `${baseSlug}-${counter}`
      counter++
    }

    return slug
  }

  private static async notifySubscribers(post: Post): Promise<void> {
    // Implementation
  }

  private static async updateSearchIndex(post: Post): Promise<void> {
    // Implementation
  }

  private static async removeFromSearchIndex(post: Post): Promise<void> {
    // Implementation
  }
}

Database Migrations

Create Module Migrations

node ace make:migration create_posts_table
database/migrations/xxx_create_posts_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'posts'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id')
      table.integer('author_id').unsigned().references('users.id').onDelete('CASCADE')
      table.string('title', 255).notNullable()
      table.string('slug', 255).notNullable().unique()
      table.text('content').notNullable()
      table.text('excerpt').nullable()
      table.timestamp('published_at').nullable()
      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

Testing Your Extensions

Unit Tests

tests/unit/blog/post_service.spec.ts
import { test } from '@japa/runner'
import PostService from '#blog/services/post_service'

test.group('PostService', () => {
  test('generates unique slug', async ({ assert }) => {
    const slug = await PostService.generateSlug('My Awesome Post')
    assert.equal(slug, 'my-awesome-post')
  })

  test('appends counter to duplicate slugs', async ({ assert }) => {
    await Post.create({ slug: 'duplicate', title: 'Test' })
    const slug = await PostService.generateSlug('Duplicate')
    assert.equal(slug, 'duplicate-1')
  })
})

Functional Tests

tests/functional/blog/posts.spec.ts
import { test } from '@japa/runner'
import User from '#users/models/user'
import Roles from '#users/enums/role'

test.group('Posts', (group) => {
  test('authenticated user can create post', async ({ client }) => {
    const user = await User.create({
      email: '[email protected]',
      roleId: Roles.USER,
    })

    const response = await client
      .post('/blog/posts')
      .loginAs(user)
      .form({
        title: 'Test Post',
        slug: 'test-post',
        content: 'This is a test post content.',
      })

    response.assertRedirectsToRoute('blog.posts.show')
  })

  test('guest cannot create post', async ({ client }) => {
    const response = await client.post('/blog/posts').form({
      title: 'Test Post',
      slug: 'test-post',
      content: 'This is a test post content.',
    })

    response.assertRedirectsToRoute('auth.signIn.show')
  })
})

Best Practices

Keep your code organized by placing related files in the same module. This makes the codebase more maintainable and easier to navigate.
Use the configured path aliases (#blog/*, #users/*, etc.) instead of relative imports for cleaner code.
Study the existing modules (users, auth) and follow their patterns for consistency.
Controllers should be thin - move complex business logic to service classes.
Use VineJS validators for all user input to ensure type safety and validation.
Write tests for new features to ensure they work correctly and don’t break existing functionality.

Common Customizations

Adding a New Role

app/users/enums/role.ts
enum Roles {
  ADMIN = 1,
  USER = 2,
  EDITOR = 3, // New role
  VIEWER = 4, // New role
}

Custom Email Templates

Add custom mail templates in resources/mails/:
{{-- resources/mails/post_published.edge --}}
@component('mail/main')
  <h1>Your post has been published!</h1>
  <p>Hi {{ user.fullName }},</p>
  <p>Your post "{{ post.title }}" is now live.</p>
  <a href="{{ postUrl }}">View Post</a>
@end

Custom Middleware

app/blog/middleware/track_post_view_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import PostView from '#blog/models/post_view'

export default class TrackPostViewMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    await next()

    // Track post view after response
    if (ctx.params.id && ctx.auth.user) {
      await PostView.create({
        postId: ctx.params.id,
        userId: ctx.auth.user.id,
      })
    }
  }
}

Next Steps

Creating Modules

Deep dive into the module system

Project Structure

Understand the modular architecture

Custom Abilities

Create custom authorization logic

File Attachments

Add file upload capabilities

Build docs developers (and LLMs) love