Skip to content

Repository files navigation

EasyAdmin for Spring Boot

Java Spring Boot License Status

Alpha / work in progress. The API is not yet stable — expect breaking changes between 0.x releases. Not yet published to Maven Central.

A configuration-driven admin-panel generator for Spring Boot applications.

It builds admin screens at runtime from configuration, not by generating source you then have to maintain. Point it at a JPA entity and you get a listing with search, sorting, filtering and pagination, a detail page, and create/edit/delete forms.

@AdminCrud
public class ProductCrudController extends AbstractCrudController<Product> {
}

That is a complete, working admin screen. Fields, labels, column order, sortability, form widgets and validation are all derived from the entity's JPA metadata.

Listing screen with search, filters, sorting and export
Create form generated from JPA metadata

What you get out of the box

  • Listing with full-text search across chosen properties (including nested paths), multi-column sorting, pagination and a bookmarkable filter panel
  • Detail, create, edit, delete and batch-delete pages
  • 26 field types, from TextField to MoneyField, SlugField and ImageField
  • 6 filter types with 15 operators, inferred from persistence metadata when you don't declare them
  • @OneToMany collections edited inline as a repeating sub-form
  • Listing cells editable in place, through the same binder, validation and permissions as a form
  • CSV and Excel export of exactly what the listing is showing, written without a spreadsheet dependency
  • Custom entity, global and batch actions declared in the DSL and handled by an annotated method
  • Field-, action-, filter- and menu-level permissions enforced on the server
  • File and image uploads behind a three-method storage SPI
  • Dashboard with a nested menu, dark mode, and per-request locale
  • Full i18n through your application's own MessageSource (English and Turkish bundled)
  • One self-contained jar: no CDN request, no build step, no bundled JavaScript framework

Table of contents


Requirements

Spring Boot 2.7 line Spring Boot 3.x and 4.x line
Artifact easyadmin-spring-boot-starter easyadmin-spring-boot-starter-jakarta
Java 8 or later 17 or later
Persistence namespace javax.persistence jakarta.persistence

The jakarta starter covers Boot 4 as well as Boot 3 — Boot 4 rearranged the auto-configuration classes into per-technology modules, but nothing in the API this library builds on changed.

Thymeleaf comes with the starter — it renders every admin page, and your application may have no other use for it. Spring Data JPA is yours to declare: the starter brings the admin backend, not your persistence layer. Spring Security is optional; without it every permission check passes.

Installation

Spring Boot 2.7 / Java 8

<dependency>
  <groupId>io.github.cankurucu.easyadmin</groupId>
  <artifactId>easyadmin-spring-boot-starter</artifactId>
  <version>0.1.0</version>
</dependency>

Spring Boot 3.x or 4.x / Java 17+

<dependency>
  <groupId>io.github.cankurucu.easyadmin</groupId>
  <artifactId>easyadmin-spring-boot-starter-jakarta</artifactId>
  <version>0.1.0</version>
</dependency>

Gradle:

implementation 'io.github.cankurucu.easyadmin:easyadmin-spring-boot-starter-jakarta:0.1.0'

Pre-release. 0.1.0 is not on Maven Central yet. Until it is, clone this repository and run mvn install to publish the artifacts to your local ~/.m2.

The backend mounts at /admin. Nothing else is required — no @Enable annotation, no XML, no component-scan tweak.

⚠️ Restricting who reaches /admin is still your job. With Spring Security on the classpath the library enforces the permissions declared in the DSL, but it does not decide who may open the backend at all — add a URL rule for the base path yourself. See Security and permissions.

Quick start

1. An entity

Nothing about it is admin-specific — it is your ordinary JPA entity.

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false, length = 120)
    private String name;

    private String sku;
    private BigDecimal price;
    private Integer stock;
    private Boolean active;
    private LocalDate releasedOn;

    @Enumerated(EnumType.STRING)
    private Status status;

    @ManyToOne
    private Category category;

    @Column(updatable = false)
    private LocalDateTime createdAt;

    public enum Status { DRAFT, PUBLISHED, ARCHIVED }

    // getters and setters
}

2. A CRUD controller

@AdminCrud
public class ProductCrudController extends AbstractCrudController<Product> {
}

@AdminCrud is meta-annotated with @Component, so component scanning picks it up. The URL slug comes from the class name (ProductCrudControllerproduct), and the entity type from the generic parameter — there is no getEntityFqcn() to repeat.

Visit http://localhost:8080/admin/product and the screen is already there: every persistent property becomes a column and a form widget chosen from its type, @Column(nullable = false) makes the widget required, and Bean Validation constraints such as @NotBlank are enforced on submit with the messages rendered against the offending field.

3. Lock down the base path

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().permitAll())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}

4. Configure the screen when the defaults stop being enough

@AdminCrud(order = 10)
public class ProductCrudController extends AbstractCrudController<Product> {

    @Override
    public Crud configureCrud(Crud crud) {
        return crud
            .setEntityLabel("Product", "Products")
            .setDefaultSort(SortSpec.desc("createdAt"))
            .setSearchProperties("name", "sku", "category.name")   // nested paths join
            .setPageSize(30)
            .setHelp(CrudPage.INDEX, "Everything in the catalogue.");
    }

    @Override
    public List<Field> configureFields(CrudPage page) {
        return Arrays.asList(
            IdField.create("id").onlyOnIndex(),
            TextField.create("name", "Product name").setMaxLength(40).setColumns(8),
            TextField.create("sku", "SKU").setColumns(4),
            NumberField.create("price").setDecimals(2).setSuffix("TRY").setColumns(4),
            IntegerField.create("stock").setColumns(4),
            ChoiceField.create("status").setChoicesFromEnum(Product.Status.class).setColumns(4),
            AssociationField.create("category").setDisplayProperty("name").setColumns(6),
            BooleanField.create("active").setColumns(6),
            TextareaField.create("description").setRows(4).hideOnIndex(),
            TextField.create("internalNote").setPermission("ROLE_ADMIN").hideOnIndex(),
            DateTimeField.create("createdAt").hideOnForm());
    }

    @Override
    public Filters configureFilters(Filters filters) {
        return filters
            .add("status")
            .add("active")
            .add(NumericFilter.create("price", "Price"))
            .add(EntityFilter.create("category").setDisplayProperty("name"));
    }

    @Override
    public Actions configureActions(Actions actions) {
        return actions
            .add(CrudPage.INDEX, Action.DETAIL)
            .setPermission(Action.DELETE, "ROLE_ADMIN");
    }

    @Override
    public void onBeforeSave(Product product, boolean creating) {
        if (creating) {
            product.setCreatedAt(LocalDateTime.now());
        }
    }
}

A runnable version of all of this lives in examples/demo-app.

How it works

The whole backend is served by a single @Controller owning one /{crud}/... pattern set, which resolves the target controller from a registry built at startup. Adding a CRUD controller therefore never touches routing — there is no route to declare, register or keep in sync.

Every request is turned into an AdminContext (dashboard, CRUD config, entity metadata, page, search term, sort, filters, locale), passed to the factories that build fields, actions, filters and the menu, and rendered by Thymeleaf templates that ship inside the jar.

Persistence sits behind two interfaces, AdminMetadataProvider and AdminEntityRepository. Supporting another storage technology means implementing those two, nothing else.

Everything the auto-configuration registers is @ConditionalOnMissingBean, so any single piece — the permission checker, the field factory, the file storage, the ID converter — can be replaced by declaring your own bean of that type.

The CRUD controller

AbstractCrudController<T> implements AdminCrudController<T>. Every method has a default, so you override only what you need.

Method Purpose
configureCrud(Crud) Labels, sorting, search, pagination, formats, page-level settings
configureFields(CrudPage) Which fields appear on which page, and how
configureActions(Actions) Which buttons appear, their permissions, ordering and variants
configureFilters(Filters) The filter panel above the listing (opt-in)
createEntity() Builds the instance behind the "new" form
customizeQuery(AdminQuery.Builder, AdminContext) Last-chance query adjustment: tenant scoping, soft deletes
onBeforeSave / onAfterSave Audit fields, denormalisation, events
onBeforeDelete / onAfterDelete Cascade cleanup, soft delete bookkeeping
getSlugOverride() Forces the URL slug; normal controllers use @AdminCrud(slug = "...")

@AdminCrud itself takes two attributes:

@AdminCrud(slug = "catalog-settings", order = 30)
  • slug — URL segment; derived from the class name when omitted (OrderLineCrudControllerorder-line)
  • order — sort weight in the auto-generated menu; ignored when the dashboard declares its menu

configureCrud

@Override
public Crud configureCrud(Crud crud) {
    return crud
        .setEntityLabel("Product", "Products")
        .setPageTitle(CrudPage.INDEX, "Catalogue")
        .setHelp(CrudPage.EDIT, "Price changes take effect immediately.")
        .setDefaultSort(SortSpec.desc("createdAt"), SortSpec.asc("name"))
        .setSearchProperties("name", "sku", "category.name")
        .setSearchMode(SearchMode.ALL_TERMS)
        .setPageSize(30)
        .setPaginatorRangeSize(3)
        .setDateTimeFormat("dd.MM.yyyy HH:mm")
        .setThousandsSeparator(".")
        .setDecimalSeparator(",")
        .setDefaultRowAction(Action.EDIT)
        .showEntityActionsAsDropdown(true)
        .hideNullValues(true)
        .setEntityPermission("ROLE_CATALOG");
}

Full reference

Method Effect
setEntityLabel(singular, plural) Both labels at once
setEntityLabelInSingular / setEntityLabelInPlural Individually
setPageTitle(CrudPage, title) Heading for one page
setHelp(CrudPage, message) Help text under the heading
setDateFormat / setTimeFormat / setDateTimeFormat java.time patterns
setNumberFormat(pattern) DecimalFormat pattern
setThousandsSeparator / setDecimalSeparator Number rendering
setTimezone(zoneId) Zone used to render temporal values
setDefaultSort(SortSpec...) Multi-column default ordering
setDefaultSort(property, SortDirection) Single-column shorthand
setSearchProperties(String...) Properties the search box queries; dotted paths join
setSearchMode(SearchMode) ALL_TERMS (AND) or ANY_TERMS (OR)
disableSearch() Removes the search box
setPageSize(int) Rows per page
setPaginatorRangeSize(int) Page links on each side of the current page
showEntityActionsAsDropdown(boolean) Collapses per-row buttons into a menu
hideNullValues(boolean) Renders empty values as nothing instead of a placeholder
setDefaultRowAction(actionName) Makes the whole row a link to that action
setEntityPermission(permission) Guards the entire screen
enableExport(ExportFormat...) / disableExport() Download links above the listing; see Export
setExportFileName / setExportMaxRows / setExportPermission Export details
overrideTemplate(name, path) Swaps one template for this controller only

CrudPage is an enum: INDEX, DETAIL, NEW, EDIT.

configureFields

Returning an empty list — the default — makes the backend derive fields from persistence metadata. That is what zero-configuration mode relies on. Return a list and you take control:

@Override
public List<Field> configureFields(CrudPage page) {
    if (page == CrudPage.INDEX) {
        return Arrays.asList(
            TextField.create("name"),
            NumberField.create("price").setDecimals(2),
            BooleanField.create("active"));
    }
    return Arrays.asList(
        TextField.create("name").setColumns(8),
        TextField.create("sku").setColumns(4),
        NumberField.create("price").setDecimals(2),
        TextareaField.create("description").setRows(6),
        AssociationField.create("category").setAutocomplete(true));
}

Branching on page is optional — the onlyOn* / hideOn* family usually reads better:

IdField.create("id").onlyOnIndex()
TextField.create("slug").onlyWhenCreating()
DateTimeField.create("createdAt").hideOnForm()
TextField.create("note").hideOn(CrudPage.INDEX, CrudPage.NEW)

configureActions

@Override
public Actions configureActions(Actions actions) {
    return actions
        .add(CrudPage.INDEX, Action.DETAIL)
        .add(CrudPage.DETAIL, Action.create("print", "Print")
            .setIcon("file")
            .linkToUrl(entity -> "/reports/" + ((Product) entity).getId()))
        .addBatchAction(Action.create("publish", "Publish selected")
            .linkToCrudAction("publish").asPostRequest())
        .update(CrudPage.INDEX, Action.DELETE, action -> action.askConfirmation("Are you sure?"))
        .reorder(CrudPage.INDEX, Action.DETAIL, Action.EDIT, Action.DELETE)
        .disable(Action.BATCH_DELETE)
        .setPermission(Action.DELETE, "ROLE_ADMIN");
}

See Actions for the full API.

configureFilters

Filters are opt-in — a controller that configures none renders no panel. See Filters.

Lifecycle hooks and the query

@Override
public void customizeQuery(AdminQuery.Builder query, AdminContext context) {
    query.addFilter(FilterCriteria.of("tenantId", FilterOperator.EQ, currentTenantId()))
         .addFilter(FilterCriteria.of("deletedAt", FilterOperator.IS_NULL));
}

@Override
public void onBeforeSave(Product product, boolean creating) {
    product.setUpdatedAt(LocalDateTime.now());
    if (creating) {
        product.setCreatedBy(currentUser());
    }
}

@Override
public void onBeforeDelete(Product product) {
    if (product.getStock() > 0) {
        throw new IllegalStateException("Cannot delete a product still in stock");
    }
}

@Override
public Product createEntity() {
    Product product = new Product();
    product.setActive(Boolean.TRUE);
    product.setStatus(Product.Status.DRAFT);
    return product;
}

Search, filters, sorting and pagination are already applied to the builder by the time customizeQuery runs, so anything you add is an additional constraint the user cannot remove by editing the URL.

Field types

26 types, each created with create(property) or create(property, label):

Category Types
Text TextField, TextareaField, SlugField, EmailField, UrlField, TelephoneField, ColorField
Numeric IntegerField, NumberField, MoneyField, PercentField
Temporal DateField, DateTimeField
Choice & relations BooleanField, ChoiceField, AssociationField, CollectionField
Identity IdField
Locale data CountryField, CurrencyField, LocaleField, TimezoneField
Files FileField, ImageField
Editors TextEditorField, CodeEditorField

Options every field has

Method Effect
setLabel(String) / hideLabel() Column heading and form label
formatValue(Function<Object, String>) Renders the value your way on read-only pages
setVirtual(boolean) Marks a property that is computed, not persisted
setRequired(boolean) / setDisabled(boolean) Form widget state
setSortable(boolean) Whether the column header is a sort link
setHelp(String) Hint under the widget
setColumns(int) Width on a 12-column form grid
setTextAlign(TextAlign) LEFT, CENTER, RIGHT
setCssClass / addCssClass Styling hooks
setHtmlAttribute(name, value) / setHtmlAttributes(Map) Arbitrary attributes on the widget
setInlineEditable(boolean) Makes the listing cell editable in place — see Inline listing edits
setPermission(String) Removes the field for users without the authority
setTemplatePath(String) Renders through your own fragment
setCustomOption(name, value) Passes data through to a custom template
onlyOnIndex() onlyOnDetail() onlyOnForms() onlyWhenCreating() onlyWhenUpdating() Visibility
hideOnIndex() hideOnDetail() hideOnForm() hideWhenCreating() hideWhenUpdating() Visibility
displayOn(CrudPage...) / hideOn(CrudPage...) Explicit page lists

Type-specific options

// Text — setMaxLength truncates on the listing; detail and form pages show the value in full
TextField.create("name").setMaxLength(40).renderAsHtml(false)
TextareaField.create("description").setRows(6).setMaxLength(2000)
SlugField.create("slug").setSourceProperty("name").setSeparator("-")
UrlField.create("website").setTarget("_blank").setRel("noopener")
ColorField.create("brandColor").showValue(true)

// Numeric
IntegerField.create("stock").setMin(0).setMax(9999).setThousandsSeparator(".")
NumberField.create("weight").setDecimals(3).setRoundingMode(RoundingMode.HALF_UP).setSuffix("kg")
MoneyField.create("price").setCurrency("TRY").storedAsMinorUnit(true)
MoneyField.create("price").setCurrencyProperty("currencyCode")   // per-row currency
PercentField.create("commissionRate").storedAsFraction(true).setDecimals(1)

// Temporal
DateField.create("releasedOn").setFormat("dd.MM.yyyy")
DateTimeField.create("createdAt").setFormat("dd.MM.yyyy HH:mm").setTimezone("Europe/Istanbul")

// Choice and boolean
BooleanField.create("active").renderAsSwitch(true).setLabels("Live", "Paused")
ChoiceField.create("status").setChoicesFromEnum(Status.class)
ChoiceField.create("tags").allowMultipleChoices(true).renderExpanded(true)
ChoiceField.create("status")
    .setChoices(choices)
    .renderAsBadges(true)
    .setBadgeClasses(Collections.singletonMap("ARCHIVED", "eadmin-badge--danger"))

// Associations
AssociationField.create("category")
    .setDisplayProperty("name")
    .setLabelFormatter(c -> ((Category) c).getCode() + " — " + ((Category) c).getName())
    .setCrudController(CategoryCrudController.class)   // where the link points
    .setQueryLimit(500)
    .setAutocomplete(true)

// Locale data
CountryField.create("country")
CurrencyField.create("currency").showSymbol(true)
LocaleField.create("language")
TimezoneField.create("zone").setRegion("Europe")

// Files
ImageField.create("logo").setDirectory("logos").setThumbnailSize(96).setMaxSize(2 * 1024 * 1024)
FileField.create("contract").setDirectory("contracts").setAllowedExtensions("pdf", "docx")

CollectionField has a section of its own — see Collections.

storedAsMinorUnit and storedAsFraction are explicit rather than guessed, because guessing wrong is a factor of 100. SlugField generates from its source field only while the input is still empty — regenerating a published slug would break existing links.

Locale-data options come from the JDK's own data and are named in the request's locale, so there is no bundled dataset to go stale.

Collections

A @OneToMany or @ManyToMany edited on the parent's own form, as a repeating sub-form:

CollectionField.create("variants", "Variants")
    .setEntryFields(
        TextField.create("sku").setColumns(5),
        TextField.create("size").setColumns(3),
        IntegerField.create("stock").setColumns(4))
    .setAddLabel("Add variant")
    .setMaxEntries(20)
    .onlyOnForms()

Leave setEntryFields out and the entry's fields are derived from its metadata the same way a zero-configuration screen derives a form — minus the identifier, which travels in a hidden input, and minus the back-reference to the parent, which is set from the parent rather than chosen.

What the entity has to allow. The parent is saved once, with the collection already rebuilt, so the association needs a cascade that reaches entries the form created:

@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductVariant> variants = new ArrayList<>();

Without a cascade, JPA has no instruction to insert the rows the form built and they are dropped at flush.

What removing an entry means is a decision the library will not make for you. Unticking a row always detaches it from the collection; whether it is then deleted is deleteRemovedEntries(boolean), which defaults to false. For order lines a detached row is garbage, for a tag assignment it is still a perfectly good record — and of the two mistakes, leaving a row behind is the recoverable one. When the mapping already declares orphanRemoval, JPA does the deleting and the setting has nothing left to do.

Method Effect
setEntryFields(Field...) The fields of one entry; derived from metadata when unset
allowAdd(boolean) / allowDelete(boolean) Hides the add control or the per-row remove box
deleteRemovedEntries(boolean) Deletes a detached entry instead of only unlinking it
setMaxEntries(int) Caps the entries the form accepts; extra submitted rows are ignored
setMappedBy(String) The back-reference property, when it cannot be inferred
setAddLabel(String) Label of the add button

Binding. Each entry binds under an indexed name — variants[0].sku — alongside a hidden variants[0].__id for a row that already exists and a variants[0].__delete checkbox. A row with no __id is new. Indices are read off the submitted parameter names rather than assumed to run 0..n, so removing the middle row in the browser leaves a harmless gap.

An entry naming an identifier that is not already in this collection is ignored, not loaded. The id arrives in a hidden input, so honouring it would let a crafted submit pull an arbitrary row of the child table into this record.

Without JavaScript the existing rows stay editable and the remove checkboxes still work on submit; only adding a row needs the script. The row it clones is rendered by the server and hidden in a <template>, so a new row is built from the same markup as an existing one rather than from a second copy maintained in JavaScript.

Nesting stops at one level: a collection inside a collection entry would need a second index, which neither the binder nor the add control produces, so one is dropped from the entry fields rather than rendered broken.

Filters

@Override
public Filters configureFilters(Filters filters) {
    return filters
        .add("status")                                  // type inferred from metadata
        .add("active")
        .add("releasedOn")
        .add(NumericFilter.create("price", "Price")
            .setOperators(FilterOperator.GTE, FilterOperator.LTE, FilterOperator.BETWEEN))
        .add(EntityFilter.create("category").setDisplayProperty("name").setQueryLimit(200))
        .add(ChoiceFilter.create("status").setChoicesFromEnum(Product.Status.class))
        .add(TextFilter.create("sku").setPermission("ROLE_ADMIN"));
}

add(String) infers the filter type from the property's persistence metadata: string → text, enum → choice (options read off the enum), boolean → yes/no, temporal → date, numeric → numeric, association → entity.

Types and their operators

Filter Operators offered
TextFilter CONTAINS, NOT_CONTAINS, EQ, NEQ, STARTS_WITH, ENDS_WITH, IS_NULL, IS_NOT_NULL
NumericFilter EQ, NEQ, GT, GTE, LT, LTE, BETWEEN, IS_NULL, IS_NOT_NULL
DateFilter EQ, GTE, LTE, GT, LT, BETWEEN, IS_NULL, IS_NOT_NULL
BooleanFilter EQ, IS_NULL, IS_NOT_NULL — "not true" and "false" differ once the column is nullable, so NEQ is left out on purpose
ChoiceFilter EQ, NEQ, IN, NOT_IN, IS_NULL, IS_NOT_NULL
EntityFilter EQ, NEQ, IN, NOT_IN, IS_NULL, IS_NOT_NULL

setOperators(...) narrows that list, and an operator outside it is refused when submitted, not merely absent from the dropdown.

Choice and entity filters render their value control as a multi-select: picking one value satisfies EQ, picking several satisfies IN, so one control covers both with no JavaScript.

Bookmarkable state. Filter state lives in the query string as flt.<property>.op and flt.<property>.v, so a filtered listing can be shared or bookmarked. Repeating the value parameter is what expresses BETWEEN. Sort links, pagination links and the search box all carry the active filters forward. The panel is a <details> element submitting a plain GET form.

/admin/product?flt.price.op=BETWEEN&flt.price.v=10&flt.price.v=50&flt.status.op=IN&flt.status.v=DRAFT&flt.status.v=PUBLISHED

Forgiving by design. Filter parameters arrive from the URL bar, so an unknown property, an operator the filter does not offer, or a value that will not convert drops that one condition instead of producing an error page. Filters honour setPermission the same way fields do — one the user may not see is removed rather than hidden, so it cannot be applied by editing the URL.

Actions

Built-in actions

Action.INDEX, DETAIL, NEW, EDIT, DELETE, BATCH_DELETE, SAVE_AND_RETURN, SAVE_AND_CONTINUE, SAVE_AND_ADD_ANOTHER.

The Actions API

Method Effect
add(CrudPage, actionName) Adds a built-in action to a page
add(CrudPage, Action) Adds a configured action
addToAllPages(Action) Adds it everywhere
addBatchAction(Action) Adds an action driven by the row checkboxes
update(CrudPage, name, Consumer<Action>) Tweaks an already-registered action
remove(CrudPage, name) Removes it from one page
disable(String...) Removes it from every page
reorder(CrudPage, String...) Explicit button order
setPermission(name, permission) Guards the action, endpoint included

The Action API

Method Effect
setLabel(String) / hideLabel() Button text
setIcon(String) See Icons
setVariant(ActionVariant) DEFAULT, PRIMARY, SUCCESS, WARNING, DANGER, INFO, LINK
asPrimary() / asDanger() Shorthands for the two common variants
setCssClass / addCssClass / setHtmlAttribute(s) Escape hatches
asGlobalAction() Renders above the listing rather than per row
asBatchAction() Operates on the checked rows
linkToCrudAction(name) Targets an @AdminAction method
linkToUrl(String) / linkToUrl(Function<Object, String>) Static or per-entity URL
linkToRoute(name) Named application route
asPostRequest() Renders a POST form instead of a link
askConfirmation(message) Browser confirm before submitting
displayIf(Predicate<Object>) Per-row visibility from entity state
setPermission(String) Authority required
setTemplatePath(String) Custom rendering

Custom actions

@Override
public Actions configureActions(Actions actions) {
    return actions
        .add(CrudPage.INDEX, Action.create("restock", "Restock")
            .setIcon("plus")
            .linkToCrudAction("restock")
            .asPostRequest()
            .displayIf(entity -> ((Product) entity).getStock() < 10))
        .addBatchAction(Action.create("publish", "Publish selected")
            .linkToCrudAction("publish").asPostRequest())
        .add(CrudPage.INDEX, Action.create("export", "Export CSV")
            .asGlobalAction().linkToCrudAction("export"))
        .setPermission("restock", "ROLE_ADMIN");
}

/** Entity action: mutate in place, the dispatcher persists afterwards. */
@AdminAction("restock")
public void restock(Product product) {
    product.setStock(product.getStock() + 50);
}

/** Batch action: receives the checked rows. */
@AdminAction("publish")
public void publish(List<Product> products, AdminContext context) {
    products.forEach(p -> p.setStatus(Product.Status.PUBLISHED));
}

/** Returning a String controls the response instead. */
@AdminAction("export")
public String export(AdminContext context) {
    return "redirect:/reports/products.csv";
}

Handler parameters are matched by type in any order: the entity, a List of entities, and AdminContext. A void handler is expected to mutate the entity in place and the change is persisted afterwards; return a String to control the response — a view name or a redirect:... instruction.

Action permissions are enforced on the endpoint, not only when deciding whether to draw the button.

Association autocomplete

AssociationField.create("category").setAutocomplete(true)

Instead of loading the target table into a dropdown, the widget queries GET /{crud}/autocomplete?property=category&q=... as the user types. Only properties actually exposed as fields can be queried, so the endpoint cannot be used to enumerate other tables. Without JavaScript the control degrades to a select holding just the current value: the record stays editable, but that one association cannot be changed.

Inline listing edits

A column that is tedious to open a form for — a stock count, a status — can be edited straight from the table:

IntegerField.create("stock").setInlineEditable(true)
ChoiceField.create("status").setChoicesFromEnum(Status.class).setInlineEditable(true)

Clicking the cell turns it into a control; leaving it or pressing Enter saves; Escape abandons. The cell then shows the server's rendering of the saved value, not the text that was typed — an onBeforeSave hook may have normalised it.

Supported for the field types that fit in one cell: text, textarea, slug, email, url, telephone, colour, the numeric types, dates, booleans and a single-valued choice. Associations, uploads and the editors keep sending the user to the form, where they have the room they need.

It is a real write, through the real machinery. The value goes through the same FormBinder, the same Bean Validation pass and the same onBeforeSave / onAfterSave hooks as a full submit. A rejected value comes back as 422 with the message, and nothing is stored.

And through the same permissions. The endpoint takes a property name from the request and never trusts it: the name is looked up among the fields resolved for this user on the listing page, so a field a permission removed, a field that is not on the listing, a field that was never marked inline-editable, and the identifier are all a 404 rather than a write. The edit action's permission is enforced too — editing one cell is still editing.

POST /{crud}/{id}/inline    property=stock&value=42
→ 200 {"ok":true,"value":"42","formatted":"42"}
→ 422 {"ok":false,"error":"must be greater than or equal to 0"}

The CSRF token is published as <meta name="eadmin-csrf-token"> because a fetch has no form to inherit it from. With JavaScript off, the cell is plain read-only text and the row's Edit button is exactly where it always was.

Export

Opt-in, per controller, like filters:

crud.enableExport(ExportFormat.CSV, ExportFormat.XLSX)
    .setExportFileName("products")
    .setExportMaxRows(50_000)
    .setExportPermission("ROLE_ANALYST")

One download button per format appears above the listing. What downloads is what the listing is showing: the same columns in the same order, carrying the same formatted values, narrowed by the search term and filters in the current URL — the link is built from that URL. Pagination is the one thing dropped, up to setExportMaxRows (10,000 by default; the file is assembled in memory, so an unbounded table would be a way to exhaust the heap from a URL).

Method Effect
enableExport(ExportFormat...) Offers those formats; no arguments means all of them
disableExport() Removes the links, including ones the dashboard enabled everywhere
setExportFileName(String) Base name of the file; the extension comes from the format
setExportMaxRows(int) Hard cap on exported rows
setExportPermission(String) Authority required, on top of reaching the screen

No spreadsheet dependency. Both writers are part of this library. Apache POI would add some twenty megabytes of transitive dependencies to every application using the starter, in exchange for features an admin export never reaches for; what is left is RFC 4180 text and a zip holding five small XML parts, both of which the JDK can produce on its own.

Two details of the CSV exist because the file is opened in a spreadsheet rather than parsed by a program. It leads with a UTF-8 BOM, without which Excel decodes it in the system code page and mangles every non-ASCII character. And a value starting with =, +, - or @ is prefixed with an apostrophe unless it is a plain number — spreadsheets treat those as the start of a formula, so a record someone named =HYPERLINK(...) would otherwise execute on open. The export is the point where untrusted data crosses into a program that runs it.

Another format is one bean. AdminExportWriter has two methods, and a bean whose getFormat() matches a built-in one replaces that writer:

@Bean
AdminExportWriter csvExportWriter() {
    return new CsvExportWriter(';', true);   // semicolons, for locales whose decimal mark is a comma
}

Dashboard and menu

@AdminDashboard
public class DashboardController implements AdminDashboardController {

    @Override
    public Dashboard configureDashboard() {
        return Dashboard.newInstance()
            .setTitle("Acme Admin")
            .setLogoPath("/img/logo.svg")
            .setFaviconPath("/favicon.ico")
            .setDefaultColorScheme(ColorScheme.AUTO)
            .setLocales("en", "tr")
            .renderSidebarMinimized(false)
            .renderContentMaximized(false)
            .redirectHomeTo(ProductCrudController.class);
    }

    @Override
    public List<MenuItem> configureMenuItems() {
        return Arrays.asList(
            MenuItem.linkToDashboard("Dashboard", "home"),
            MenuItem.section("Catalog"),
            MenuItem.linkToCrud(ProductCrudController.class, "Products", "box")
                .setBadge("12", "eadmin-badge--success"),
            MenuItem.linkToCrud(CategoryCrudController.class, "Categories", "tag"),
            MenuItem.subMenu("Reports", "chart").addChildren(
                MenuItem.linkToUrl("Sales", "chart", "/reports/sales"),
                MenuItem.linkToUrl("Stock", "box", "/reports/stock").openInNewTab()),
            MenuItem.section("System"),
            MenuItem.linkToUrl("Docs", "external-link", "https://example.com/docs")
                .openInNewTab()
                .setPermission("ROLE_ADMIN"),
            MenuItem.linkToLogout("Sign out", "log-out"));
    }

    /** Defaults every CRUD controller inherits unless it overrides them. */
    @Override
    public Crud configureCrud(Crud crud) {
        return crud
            .setDateFormat("dd.MM.yyyy")
            .setDateTimeFormat("dd.MM.yyyy HH:mm")
            .setPageSize(25);
    }
}

Dashboard options

Method Effect
setTitle / setLogoPath / setFaviconPath Branding
disableFavicon() Declares no tab icon at all — see below
setDefaultColorScheme(ColorScheme) LIGHT, DARK or AUTO (follows the OS)
disableDarkMode() Removes the toggle entirely
setTextDirection(String) ltr or rtl
renderSidebarMinimized(boolean) Collapsed sidebar by default
renderContentMaximized(boolean) Full-width content area
setLocales(String...) Language switcher entries
redirectHomeTo(Class) Sends /admin straight to a CRUD screen

Menu item factories: linkToDashboard, linkToCrud, linkToUrl, linkToRoute, section, subMenu, linkToLogout. Each item supports addChildren, setCssClass, setPermission, openInNewTab and setBadge(text[, cssClass]).

The tab icon has a default, /easyadmin/favicon.ico, served from inside the jar. Not decoration: a page that declares no icon makes the browser go looking for /favicon.ico at the application root, which most applications do not serve — and on Spring Boot 3 and 4 that miss is logged as a NoResourceFoundException on every admin page load. setFaviconPath replaces it; disableFavicon() leaves the tab blank by declaring an empty icon, so the root lookup stays suppressed either way.

The jar also answers /favicon.ico at the root. That covers the pages this library does not render — your login screen, Spring's whitelabel error page — where nothing declares an icon and the browser falls back to the root. Know what it costs before you keep it:

Spring Boot searches classpath:/META-INF/resources/ before classpath:/static/, and the file ships in the first of those. So an application that drops its own favicon.ico into src/main/resources/static/ — where everyone puts it — will still be serving this library's icon, with nothing in the log to say why. Measured, not assumed: with both files present the bytes on the wire are the jar's.

To take the root back, put your icon in src/main/resources/META-INF/resources/favicon.ico instead. Same directory, and your own classes win over a dependency's.

AdminDashboardController also declares configureActions and configureFilters, so the dashboard is where you put conventions that should hold across every screen — a global date format, a BATCH_DELETE you never want, a permission every action inherits. Individual controllers override what they need.

The dashboard is optional. Without one you get a default whose menu lists every registered CRUD controller, ordered by @AdminCrud(order = ...).

Icons

Anywhere an icon is accepted — MenuItem and Action.setIcon — the value may be one of three things, and the backend works out which:

You pass You get
A built-in name, e.g. "pencil" An inline SVG that ships in the stylesheet. No font, no request
Icon-font classes, e.g. "fa-solid fa-cube" The classes verbatim, for you to style with the font your app already loads
A literal character, e.g. "→" The character, printed as text

The 45 built-in names:

arrow-left arrow-right bell box calendar cart chart check clock close copy credit-card database download external-link eye file filter folder globe grid home image info key link list lock log-out mail pencil phone plus refresh search settings shield star tag trash truck upload user users warning

Common synonyms resolve to the same drawing, so the obvious guess works: editpencil, deletetrash, exportdownload, customersusers, analyticschart. An unrecognised name is printed as text rather than silently drawing nothing, so a typo is visible.

Built-in icons are painted with currentColor, so they follow the surrounding link or button colour and need no separate dark-mode drawing. To restyle one, override its custom property:

.eadmin-icon--trash { --eadmin-icon-svg: url("data:image/svg+xml,..."); }

Security and permissions

With Spring Security on the classpath, permission strings are matched against the user's granted authorities by exact name — no implicit ROLE_ prefixing, so write "ROLE_ADMIN" when that is the authority. Anonymous users are treated as unauthenticated even though their token reports otherwise. Without Spring Security the checker permits everything.

Permissions apply at four levels:

// Whole screen
crud.setEntityPermission("ROLE_CATALOG")

// Menu item
MenuItem.linkToCrud(AuditCrudController.class, "Audit log", "shield").setPermission("ROLE_ADMIN")

// Action — enforced on the endpoint too
actions.setPermission(Action.DELETE, "ROLE_ADMIN")

// Field
TextField.create("internalNote").setPermission("ROLE_ADMIN")

// Filter
TextFilter.create("ownerEmail").setPermission("ROLE_ADMIN")

A field the user may not see is removed from the field list, not merely hidden — which also makes it unwritable, so a hand-crafted POST cannot set it.

Per-row rules come from a PermissionEvaluator bean, which receives the entity as the subject:

@Bean
PermissionEvaluator permissionEvaluator() {
    return new PermissionEvaluator() {
        @Override
        public boolean hasPermission(Authentication auth, Object target, Object permission) {
            if (target instanceof Product) {
                return ((Product) target).getOwner().equals(auth.getName());
            }
            return true;
        }
        // ...
    };
}

Replacing the mechanism entirely means one bean:

@Bean
AdminPermissionChecker adminPermissionChecker() {
    return (permission, subject) -> myOwnAuthorizationService.allows(permission, subject);
}

CSRF is handled by Thymeleaf's Spring dialect: every form the backend renders uses th:action, so the hidden token is injected automatically when Spring Security's CSRF protection is on. Nothing to configure.

File and image uploads

ImageField.create("logo").setDirectory("logos").setMaxSize(2 * 1024 * 1024)
FileField.create("contract").setDirectory("contracts").setAllowedExtensions("pdf", "docx")

The entity column stores the storage key, not the bytes — a plain String property. Files go through the AdminFileStorage SPI — three methods — so pointing at S3 or a blob store means one bean:

@Bean
AdminFileStorage adminFileStorage() {
    return new MyS3Storage(bucket);
}

The default implementation, LocalFileStorage, writes to easyadmin.upload-dir.

⚠️ Serving those files is your job. The library stores them and renders links, but exposes no download endpoint, because who may read an upload depends on rules only your application knows.

The three-line version for a public upload directory:

@Configuration
public class UploadServingConfig implements WebMvcConfigurer {

    @Value("${easyadmin.upload-dir:uploads}")   private String uploadDir;
    @Value("${easyadmin.upload-url-prefix:/uploads}") private String uploadUrlPrefix;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        String location = Paths.get(uploadDir).toAbsolutePath().normalize().toUri().toString();
        registry.addResourceHandler(uploadUrlPrefix + "/**").addResourceLocations(location);
    }
}

Remember that Spring caps multipart requests independently of the per-field limit:

spring.servlet.multipart:
  max-file-size: 10MB
  max-request-size: 12MB

Safety. Extension and size limits are enforced on the server, not just as an accept attribute — the attribute filters the file picker and nothing else. Uploaded names are sanitised before they reach the filesystem, and LocalFileStorage additionally refuses any key that resolves outside its root. Leaving the file input untouched keeps the current file; clearing it is a separate, explicit checkbox.

Rich text and code editors

TextEditorField.create("body").setRows(12).setEditorConfig("{\"toolbar\":\"minimal\"}")
CodeEditorField.create("config").setLanguage("json").setRows(20)

No editor library is bundled. Vendoring a minified editor into the jar would contradict two things this project commits to: a small self-contained artifact and no build step. The widget renders a textarea carrying data-eadmin-editor="text" or "code" (plus data-eadmin-editor-language / data-eadmin-editor-config), which you attach your own editor to:

document.querySelectorAll('[data-eadmin-editor="code"]').forEach(el =>
    CodeMirror.fromTextArea(el, {mode: el.dataset.eaEditorLanguage}));

With nothing attached the field is still a working textarea.

Translation

Every label and chrome string resolves through three tiers, in order: the application's own MessageSource, the bundle inside this jar (English and Turkish), then the literal default. Overriding one string means defining one key in your messages.properties; everything else keeps working.

easyadmin.action.new=Yeni kayıt
easyadmin.entity.Product.plural=Ürünler
easyadmin.field.Product.sku=Stok kodu
easyadmin.ui.search=Ara

Key shapes

Pattern Example
easyadmin.action.<name> easyadmin.action.delete
easyadmin.entity.<SimpleName> / .plural easyadmin.entity.Product.plural
easyadmin.field.<SimpleName>.<property> easyadmin.field.Product.sku
easyadmin.field.<property> fallback across entities
easyadmin.ui.<name> easyadmin.ui.noRecords

A label set explicitly in the DSL wins unless a key exists for it. The library never registers a bean named messageSource, so your own translations are untouched.

Configuration properties

Property Default Meaning
easyadmin.base-path /admin Where the backend is mounted
easyadmin.page-size 20 Default rows per page
easyadmin.logout-url /logout Target of MenuItem.linkToLogout
easyadmin.upload-dir uploads Where LocalFileStorage writes
easyadmin.upload-url-prefix /uploads URL prefix used when rendering upload links
easyadmin.auto-register-entities false Generate a screen for every entity without a controller. Prototyping only — generated screens have no permissions
easyadmin:
  base-path: /admin
  page-size: 25
  upload-dir: /var/data/uploads
  upload-url-prefix: /uploads

URL map

Everything below is relative to easyadmin.base-path.

Method Path Purpose
GET / Dashboard
GET /{crud} Listing — accepts q, page, sort, dir, flt.*
GET /{crud}/new Create form
POST /{crud}/new Create
GET /{crud}/{id} Detail
GET /{crud}/{id}/edit Edit form
POST /{crud}/{id}/edit Update
POST /{crud}/{id}/delete Delete
POST /{crud}/batch-delete Delete the checked rows (ids)
POST /{crud}/{id}/action/{action} Entity action
POST /{crud}/action/{action} Global or batch action
POST /{crud}/{id}/inline Single-property write (property, value), JSON
GET /{crud}/export Download the listing (format, plus the listing's own params)
GET /{crud}/autocomplete Association search (property, q)

Extending

A custom field type needs three things and no changes to the library: a Field subclass, a FieldConfigurator bean, and a Thymeleaf fragment at easyadmin/crud/field/<type>.html exposing render(field, entity, urls).

public final class RatingField extends AbstractField<RatingField> {

    public static final String TYPE = "rating";

    private RatingField(String property, String label) {
        super(property, label, TYPE);
    }

    public static RatingField create(String property) {
        return new RatingField(property, null);
    }

    public RatingField setMaxStars(int stars) {
        return setCustomOption("maxStars", Integer.valueOf(stars));
    }
}

@Component
public class RatingConfigurator implements FieldConfigurator {

    @Override
    public boolean supports(FieldDto field, EntityDto entity) {
        return RatingField.TYPE.equals(field.getFieldType());
    }

    @Override
    public void configure(FieldDto field, EntityDto entity, AdminContext context) {
        Object value = field.getValue();
        int stars = value == null ? 0 : ((Number) value).intValue();
        StringBuilder rendered = new StringBuilder();
        for (int i = 0; i < stars; i++) {
            rendered.append('★');
        }
        field.setFormattedValue(rendered.toString());
    }
}

Configurators run as a chain ordered by getOrder(): ORDER_PRE (label derivation, value reading), then ORDER_TYPE — the default, where yours belongs — then ORDER_POST (formatting fallback, null handling). The DTO handed to configure is a per-row copy, so mutating it is safe.

Overriding a template: drop a file at the same path under src/main/resources/templates/, e.g. templates/easyadmin/crud/index.html. The application's copy wins over the jar's. To override a template for one controller only, use crud.overrideTemplate(name, path).

Restyling: the stylesheet is entirely CSS custom properties. Redefining :root in your own stylesheet rebrands the backend; there is no build step and no CDN request.

:root {
  --eadmin-primary: #6d28d9;
  --eadmin-radius: 10px;
  --eadmin-font: "Inter", system-ui, sans-serif;
  --eadmin-sidebar-bg: #17131f;
  --eadmin-sidebar-width: 280px;
}

The full set covers typography (--eadmin-font, --eadmin-font-mono), surfaces (--eadmin-bg, --eadmin-surface, --eadmin-surface-alt, --eadmin-border, --eadmin-text, --eadmin-text-muted), the sidebar (--eadmin-sidebar-*), semantic colours (--eadmin-primary, --eadmin-success, --eadmin-warning, --eadmin-danger, --eadmin-info) and shape (--eadmin-radius, --eadmin-radius-sm, --eadmin-shadow). Dark mode redefines the same names under prefers-color-scheme: dark, so overriding one name restyles both themes.

Replacing any component: everything the auto-configuration registers is @ConditionalOnMissingBeanAdminPermissionChecker, AdminFileStorage, FieldFactory, FilterFactory, MenuFactory, IdConverter, ExportService, CollectionSupport, AdminMetadataProvider, AdminEntityRepository. Declare your own bean of that type and yours is used. AdminExportWriter is the exception and works the other way round: the built-in writers are not beans, so declaring one adds or replaces a single format rather than switching the others off.

Architecture

easyadmin-core            DSL, field model, DTOs, persistence SPI.
                          No Spring, no javax/jakarta - shared by both distributions.
easyadmin-jpa-javax       JPA adapter for Boot 2.7. Metadata via the JPA Metamodel,
                          queries via the Criteria API.
easyadmin-jpa-jakarta     The same source, generated at build time with one package
                          renamed. A fix lands in both namespaces or neither.
easyadmin-security        Spring Security implementation of the permission SPI.
easyadmin-web             Dispatcher, factories, form and filter layers, templates, assets.
                          Never imports the servlet API, so one jar serves both Boot lines.
easyadmin-autoconfigure   Auto-configuration.
easyadmin-spring-boot-starter[-jakarta]
                          The two dependency bundles users actually depend on.

Design decisions

A few choices worth stating outright, because they shape how the DSL reads:

  • Pages are an enum, not strings. CrudPage.INDEX rather than "index", so a typo is a compile error and the IDE can complete the list.
  • One setVariant(ActionVariant) rather than a method per style. Seven mutually exclusive asXxxAction() methods invite calling two of them; one setter cannot be ambiguous.
  • The entity type is inferred from AbstractCrudController<T>, so it is declared once and never repeated as a class name or FQCN string.
  • The base path is configuration, not an annotation attribute, because Spring declares routing statically and the backend has to mount somewhere before any controller exists.
  • One dispatcher controller, not a route per action. The registry resolves the target at request time, which is what keeps adding a screen down to a single class.
  • Deleting a removed collection entry is a setting, not an inference. Nothing in the mapping says whether a detached row is garbage or a record that outlives the link, and of the two possible mistakes only one is recoverable — so the default unlinks and deleteRemovedEntries(true) is something you write on purpose.
  • Export and inline editing are opt-in per controller. Both hand out more than the screen already did — every matching row at once, and a write path with no form around it — which is a decision the controller should make rather than inherit from a default.

Not in this release

A bundled editor. Deliberate, and not a gap: vendoring a minified editor would contradict the two things this project commits to — a small self-contained artifact and no build step. The hooks are there, and attaching CodeMirror or Quill to them is four lines. See Rich text and code editors.

Collections nested more than one level deep. A collection inside a collection entry needs a second index (orders[0].lines[2].sku), which neither the binder nor the add control produces. Such a field is dropped from the entry rather than rendered broken. In practice the second level is usually its own screen.

Inline editing of associations and uploads. A cell is the wrong surface for a control that needs a candidate query or a multipart request, so those field types keep sending the user to the form. See Inline listing edits for the types that are supported.

Export formats beyond CSV and XLSX. No PDF, no ODS. Both would mean a dependency, and AdminExportWriter is two methods — a format you need is one bean rather than a fork. See Export.

Building

mvn install          # requires JDK 17; core and web are compiled to Java 8 bytecode
mvn test             # unit and integration tests across all modules

Run the demo:

cd examples/demo-app && mvn spring-boot:run   # http://localhost:8080/admin

The demo app is an in-memory H2 catalogue with three CRUD controllers — one fully configured, one configured not at all, and one exercising the money/percent/slug/colour/upload field types — plus a dashboard, so it doubles as a live reference for most of this document. The product screen is the one to look at for the newer features: it exports, edits its stock and status columns in place, and manages its variants as a collection sub-form.

Contributing

Issues and pull requests are welcome. When changing the JPA adapter, remember that easyadmin-jpa-jakarta is generated from easyadmin-jpa-javax at build time: edit the javax sources and both namespaces stay in step. Please keep easyadmin-core free of Spring and persistence-API imports, and add a test to the module you touched.

License

Released under the MIT License.

About

Configuration-driven admin panel generator for Spring Boot — annotate an entity, get a full CRUD admin screen at runtime.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages