Alpha / work in progress. The API is not yet stable — expect breaking changes between
0.xreleases. 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.
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
TextFieldtoMoneyField,SlugFieldandImageField - 6 filter types with 15 operators, inferred from persistence metadata when you don't declare them
@OneToManycollections 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
- Requirements
- Installation
- Quick start
- How it works
- The CRUD controller
- Field types
- Collections
- Filters
- Actions
- Inline listing edits
- Export
- Dashboard and menu
- Icons
- Security and permissions
- File and image uploads
- Rich text and code editors
- Translation
- Configuration properties
- URL map
- Extending
- Architecture
- Design decisions
- Not in this release
- Building
- Contributing
- License
| 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.
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.0is not on Maven Central yet. Until it is, clone this repository and runmvn installto 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/adminis 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.
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
}@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 (ProductCrudController → product), 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.
@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();
}
}@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.
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.
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 (OrderLineCrudController→order-line)order— sort weight in the auto-generated menu; ignored when the dashboard declares its menu
@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.
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)@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.
Filters are opt-in — a controller that configures none renders no panel. See Filters.
@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.
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 |
| 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 |
// 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.
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.
@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.
Action.INDEX, DETAIL, NEW, EDIT, DELETE, BATCH_DELETE, SAVE_AND_RETURN,
SAVE_AND_CONTINUE, SAVE_AND_ADD_ANOTHER.
| 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 |
| 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 |
@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.
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.
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.
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
}@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/beforeclasspath:/static/, and the file ships in the first of those. So an application that drops its ownfavicon.icointosrc/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.icoinstead. 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 = ...).
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: edit → pencil,
delete → trash, export → download, customers → users, analytics → chart. 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,..."); }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.
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: 12MBSafety. 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.
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.
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=AraKey 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.
| 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: /uploadsEverything 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) |
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
@ConditionalOnMissingBean — AdminPermissionChecker, 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.
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.
A few choices worth stating outright, because they shape how the DSL reads:
- Pages are an enum, not strings.
CrudPage.INDEXrather 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 exclusiveasXxxAction()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.
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.
mvn install # requires JDK 17; core and web are compiled to Java 8 bytecode
mvn test # unit and integration tests across all modulesRun the demo:
cd examples/demo-app && mvn spring-boot:run # http://localhost:8080/adminThe 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.
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.
Released under the MIT License.

