ODIN ADMIN Docs v1.0
Back end

Integration

Turn the static pages into templates of your back end: one layout, the sidebar in one file, real data, server-side validation and saving. The examples use Laravel's Blade; the same steps apply to any framework.

The plan

  1. Copy the template's css/, js/ and images/ folders to your public folder.
  2. Make one layout from any page — everything except the content.
  3. Move the sidebar (and the top bar) into their own partial files.
  4. Mark the current menu item from the current route.
  5. Show your framework's flash messages as alerts or toasts.
  6. Build forms with server-side validation errors, and tables with server-side paging.
  7. Connect the JavaScript events and apps to your routes — see Events and Apps.

1. Assets

Copy the folders into public/odinadmin/ and keep their structure — css/style.css imports its parts with relative paths.

public/odinadmin/
├── css/
├── js/
└── images/

Link them with asset(), so the URLs work in any subfolder or domain:

Blade
<link rel="stylesheet" href="{{ asset('odinadmin/css/style.css') }}">
<script src="{{ asset('odinadmin/js/app.js') }}"></script>

Add a version to the URLs — asset('odinadmin/css/style.css') . '?v=1.0' — and change it when you update the template, so browsers don't keep the old files.

2. The layout

Take any page, keep everything except the inside of .content, and turn the changing parts into placeholders:

resources/views/layouts/admin.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="csrf-token" content="{{ csrf_token() }}">
  <title>@yield('title') · {{ config('app.name') }}</title>

  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
  @stack('styles')                               {{-- plugin CSS, before style.css --}}
  <link rel="stylesheet" href="{{ asset('odinadmin/css/style.css') }}">
</head>
<body>
<div class="app">
  @include('admin.partials.sidebar')
  <div class="backdrop" id="backdrop"></div>

  <div class="main-col">
    @include('admin.partials.topbar')

    <div class="content">
      <div class="page-header">
        <div>
          <h1 class="page-title">@yield('title')</h1>
          @hasSection('subtitle')<p class="page-sub">@yield('subtitle')</p>@endif
        </div>
        <div class="btn-row">@yield('actions')</div>
      </div>

      @include('admin.partials.flash')
      @yield('content')
    </div>

    @include('admin.partials.footer')
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ asset('odinadmin/js/app.js') }}"></script>
<script src="{{ asset('odinadmin/js/alerts.js') }}"></script>
@stack('scripts')                                {{-- plugins and page scripts --}}
</body>
</html>

A page then only holds its own content, and pushes the extra scripts it needs:

resources/views/admin/posts/index.blade.php
@extends('layouts.admin')

@section('title', 'Blog Posts')
@section('subtitle', 'Write, schedule and publish articles.')
@section('actions')
  <a href="{{ route('admin.posts.create') }}" class="btn-brand"><i class="fas fa-plus"></i> Add Post</a>
@endsection

@section('content')
  <div class="card">…</div>
@endsection

@push('scripts')
  <script src="{{ asset('odinadmin/js/tables.js') }}"></script>
@endpush

Move the <aside class="sidebar"> into admin/partials/sidebar.blade.php. Now a new menu item is one edit. Mark the current page from the route name instead of writing active into each page:

resources/views/admin/partials/sidebar.blade.php
<li class="nav-item">
  <a href="{{ route('admin.posts.index') }}"
     @class(['nav-link', 'active' => request()->routeIs('admin.posts.*')])
     @if (request()->routeIs('admin.posts.*')) aria-current="page" @endif>
    <i class="nav-ico fas fa-newspaper"></i>
    <span>Blog Posts</span>
  </a>
</li>

{{-- a submenu opens when one of its pages is current --}}
@php($settingsOpen = request()->routeIs('admin.settings.*'))
<li class="nav-item">
  <a href="#" @class(['nav-link', 'open' => $settingsOpen]) aria-expanded="{{ $settingsOpen ? 'true' : 'false' }}"
     data-toggle="submenu" data-target="#settingsWrap">
    <i class="nav-ico fas fa-sliders-h"></i><span>Settings</span><i class="nav-arrow fas fa-chevron-right"></i>
  </a>
  <div @class(['sb-subwrap', 'open' => $settingsOpen]) id="settingsWrap">
    <div class="sb-subinner">
      <ul class="sb-subnav">
        <li><a href="{{ route('admin.settings.general') }}"
               @class(['nav-link', 'active' => request()->routeIs('admin.settings.general')])>General</a></li>
      </ul>
    </div>
  </div>
</li>

Do the same with the top bar (the signed-in user's name and avatar, the language menu linked to your language routes) and the footer.

4. Flash messages

Show messages from redirect()->with('success', '…') as alerts at the top of the content…

resources/views/admin/partials/flash.blade.php
@foreach (['success' => 'fa-check-circle', 'error' => 'fa-times-circle', 'warning' => 'fa-exclamation-triangle', 'info' => 'fa-info-circle'] as $type => $icon)
  @if (session()->has($type))
    <div class="alert-soft {{ $type === 'error' ? 'danger' : $type }} mb-4" role="alert">
      <i class="fas {{ $icon }}"></i>
      <div class="alert-body">{{ session($type) }}</div>
      <button type="button" class="alert-close" data-dismiss="alert" aria-label="Close"><i class="fas fa-times"></i></button>
    </div>
  @endif
@endforeach

…or as toasts, after js/alerts.js in the layout:

Blade
@if (session('success'))
  <script>Odin.toast({ type: 'success', text: @json(session('success')) });</script>
@endif

@json escapes the text for JavaScript — never print a message into a script with {{ }} or {!! !!}.

5. Forms and validation

Validate on the server, and show each error under its field with Bootstrap's .is-invalid and .invalid-feedback:

resources/views/admin/posts/form.blade.php
<form method="POST" action="{{ route('admin.posts.store') }}">
  @csrf
  <label class="form-label" for="title">Title <span class="req">*</span></label>
  <input type="text" id="title" name="title" value="{{ old('title', $post->title) }}"
         @class(['form-control form-soft', 'is-invalid' => $errors->has('title')]) required>
  @error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror

  <button type="submit" class="btn-brand">Save</button>
</form>

A form with class="needs-validation" is checked in the browser first by js/forms.js — and in the template it is never sent. Either leave the class off and let the server validate, or keep it and send the form when it's valid: form.addEventListener('odin:form-valid', () => form.submit()). See Validation.

Delete buttons

A delete is a small form. With js/modals.js loaded, data-confirm asks first and submits the form after "Delete":

Blade
<form method="POST" action="{{ route('admin.posts.destroy', $post) }}" class="d-inline">
  @csrf
  @method('DELETE')
  <button type="submit" class="icon-btn danger" aria-label="Delete {{ $post->title }}"
          data-confirm="“{{ $post->title }}” will be deleted." data-confirm-title="Delete this post?"
          data-confirm-button="Delete">
    <i class="far fa-trash-alt"></i>
  </button>
</form>

6. Tables and paging

For real data, let the database search, filter and page. Keep the table markup; leave out js/tables.js (or keep it only for small tables), and render the page links with a paginator view in the template's markup:

resources/views/admin/partials/pagination.blade.php
@if ($paginator->hasPages())
  <nav class="pager" aria-label="Pages">
    @if ($paginator->onFirstPage())
      <span class="pager-btn disabled" aria-disabled="true"><i class="fas fa-chevron-left"></i></span>
    @else
      <a class="pager-btn" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous"><i class="fas fa-chevron-left"></i></a>
    @endif

    @foreach ($elements as $element)
      @if (is_string($element))
        <span class="pager-gap">…</span>
      @endif
      @if (is_array($element))
        @foreach ($element as $page => $url)
          @if ($page == $paginator->currentPage())
            <span class="pager-btn active" aria-current="page">{{ $page }}</span>
          @else
            <a class="pager-btn" href="{{ $url }}">{{ $page }}</a>
          @endif
        @endforeach
      @endif
    @endforeach

    @if ($paginator->hasMorePages())
      <a class="pager-btn" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next"><i class="fas fa-chevron-right"></i></a>
    @else
      <span class="pager-btn disabled" aria-disabled="true"><i class="fas fa-chevron-right"></i></span>
    @endif
  </nav>
@endif
In the table's footer
<div class="table-footer">
  <span class="table-info">Showing {{ $posts->firstItem() }}–{{ $posts->lastItem() }} of {{ $posts->total() }}</span>
  {{ $posts->withQueryString()->links('admin.partials.pagination') }}
</div>

Make the search box and filters a <form method="GET">, so their values go into the query string; withQueryString() keeps them on every page link.

7. JavaScript requests

The layout above prints the CSRF token in <meta name="csrf-token">. Send it with every fetch that changes data, as the api() helper on the Apps page does:

JavaScript
fetch('/admin/home/sections/order', {
  method: 'POST',
  headers: {
    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  },
  body: JSON.stringify({ order })
});

Then connect the events: odin:drop, odin:task-move, odin:tree-order-save, odin:nav-save… The full list is on the JavaScript page.

Other back ends

The steps are the same everywhere — only the template syntax changes:

StepDjangoRailsASP.NET Core (Razor)
Layout{% extends "admin/base.html" %} + {% block content %}layouts/admin.html.erb + yield_Layout.cshtml + @RenderBody()
Sidebar file{% include "admin/sidebar.html" %}render "admin/sidebar"<partial name="_Sidebar" />
Asset URLs{% static "odinadmin/css/style.css" %}files in public/~/odinadmin/css/style.css in wwwroot
CSRF token{% csrf_token %}; header X-CSRFTokencsrf_meta_tags; header X-CSRF-Token@Html.AntiForgeryToken()
Flash messagesmessages frameworkflash[:notice]TempData

Whatever the framework: keep the class names and data attributes exactly as in the template, and the CSS and JavaScript keep working.

ODIN ADMIN v1.0 · Documentation Last updated October 18, 2026