Getting started
Starter kits
Every new Oriel app starts from a starter kit: sign-in pages, settings and a dashboard in React, Vue, Svelte, Solid, Angular or Live, or a JSON API with tokens.
Introduction#
A starter kit gives your new app the routes, controllers, pages and tests it needs to register and sign in its users. The code is copied into your app when you create it, so it is yours: change any of it, or delete what you don't need. There is nothing to keep in sync with the kit afterwards.
The kits sign users in with Oriel's own authentication, without a separate package such as Laravel's Fortify: the controllers, or the Live components, are in your app.
Creating an application using a starter kit#
The installer asks which kit you want, or takes it as an option (see Installation):
npm create @orieljs@latest blog -- --inertia=reactnpm create @orieljs@latest blog -- --livenpm create @orieljs@latest blog -- --apiIt installs the app, sets its key, migrates the database and builds the assets, so the app runs straight away. While you work on it, run Vite and the server in two terminals:
npm run devnode oriel serve --watchThen open http://localhost:8000. serve --watch restarts the server when your server code
changes, and Vite updates your pages' scripts and styles in the browser. After a restart, reload the page once more:
Vite doesn't reload it once the new server is up (see
Running your application).
A new app sends no real mail: MAIL_MAILER=log in .env writes each mail to storage/, so the email
verification link and the password reset link are in there. Set the MAIL_* variables to send real mail.
Available starter kits#
React#
The React kit's pages are React 19 components, served through Inertia by @orieljs/ and rendered on the
server. They are written in TypeScript and styled with Tailwind CSS 4 and the Oriel theme.
Vue#
The Vue kit's pages are Vue 3 single-file components with <script setup lang="ts">, served through Inertia by
@orieljs/ and rendered on the server.
Svelte#
The Svelte kit's pages are Svelte 5 components, written with runes, served through Inertia by
@orieljs/ and rendered on the server.
Solid#
The Solid kit's pages are Solid components, served through Inertia by @orieljs/ and rendered on the
server.
Angular#
The Angular kit's pages are standalone Angular 21 components, served through Inertia by @orieljs/. A
page receives its props as signal inputs. The Angular kit renders its pages in the browser only: it has no server-side
rendering.
Live#
The Live kit's pages are Live components: TSX classes rendered on the server, whose public fields are their state and
whose public methods are the actions the browser can call. @orieljs/ keeps them interactive in the
browser. Live is Oriel's Livewire.
API#
The API kit has no pages: JSON in and out, personal access tokens, and no sessions, cookies or views. Its endpoints are listed in The API kit.
Starter kit customization#
The Inertia kits#
The five Inertia kits share their server code: the same controllers, routes and form requests. Only resources/
differs:
| Folder | What is in it |
|---|---|
resources/ | The pages: Auth/, Auth/, Auth/, Auth/, Auth/, Dashboard, Settings/, Settings/, Settings/ |
resources/ | AppLayout (the sidebar and the top bar), AuthLayout (the guest pages) and SettingsLayout (the settings tabs) |
resources/ | NavLink, SidebarNav, UserMenu, AppearanceSwitch, Icon, Brand, CopyButton, InputError and DeleteUser, with a few more per framework, such as React's Field and Status |
resources/, resources/ | The browser entry, and the server entry of the kits with server-side rendering |
resources/, checklist.ts, menus.ts, names.ts | The browser code every kit shares |
Each framework keeps the Light / Dark / System setting in its own idiom: React's hooks/, Vue's
composables/, Svelte's lib/, Solid's lib/ and Angular's
services/. The Angular kit also has PageTitleService, which sets the document's title and the
breadcrumb.
resources/ is the root view every page is rendered into. HandleInertiaRequests, in
app/, shares the validation errors and three props with every page: name, the app's name;
auth.user, the signed-in user's id, name, email and email_verified_at, or null; and appearance.
A page's props are typed. node oriel inertia:types writes resources/ from your controllers,
and node oriel route:types writes the route table behind route('dashboard') in your pages. The kits' Vite plugin
runs both when Vite starts, and again when your server code changes while npm run dev runs, and npm run typecheck
runs them before TypeScript. Inertia explains them.
The Live kit#
The Live kit's pages are components in app/, and its other markup is in resources/:
| Path | What is in it |
|---|---|
app/ | The guest pages: Login, Register, ForgotPassword and ResetPassword, and VerifyEmail |
app/ | Profile, with DeleteUserForm inside it, and Password |
app/ | The user menu's Log out button, and the sign-out the other components share |
resources/, resources/ | The dashboard and Settings → Appearance, as plain views |
resources/ | app (the sidebar and the top bar) and auth (the guest pages), the documents the pages render into |
resources/ | auth-page, settings/, field, status, nav-link, text-link, sidebar-nav, user-menu, appearance-switch, icon, brand and copy-button |
resources/ | Starts Live in the browser, with the Light / Dark / System switch, the menus, and the dashboard's Copy buttons and tick |
A Live page's route names its component: Route.get('login', Login). Every update the browser sends runs the page
route's auth, verified, guest and can middleware again, so a signed-out tab can't run an action. TypeScript's
private doesn't hide a method from the browser: name it with # or a leading _, or mark it @Locked.
Live explains components.
The dashboard#
The dashboard greets the user by first name. In the local environment, it also shows a Getting started checklist
and the app's details; elsewhere it shows neither. The checklist ticks its steps from your app's real state:
| Step | Done when |
|---|---|
| Create your account | Always: you are signed in |
| Verify your email | Your email address is verified |
| Add your first page | Your app has a named route that isn't one of the kit's or the framework's |
| Store some data | app/ has a model besides User |
| Run the tests | You tick it: the browser remembers it in localStorage |
The "Your app" card shows the environment, the Oriel version, the runtime, the database, the queue connection, the
mailer, and whether the health route's checks pass. app/ builds both; change it, or remove
the cards from the dashboard page, as you like.
Two steps have a command with a Copy button. A click copies the command and the button says "Copied" for two
seconds. A polite live region after the button says it too, so a screen reader announces it. Where the browser offers
no clipboard, as on plain http away from localhost, the button stays on "Copy".
Appearance#
Settings → Appearance offers Light, Dark and System, and new users start on System. The choice is the appearance
cookie, kept for a year and mirrored to localStorage. The browser writes it, so bootstrap/ leaves it out of
cookie encryption: EncryptCookies.except('appearance').
The HandleAppearance middleware reads the cookie for each request, so the server renders the page in the right
theme: the root view puts the dark class on <html> for Dark. For System, the head carries a short inline script,
before the stylesheet, that adds dark before the first paint when the system is dark, and the browser code follows
the system setting as it changes. The script carries the request's CSP nonce when your app sets one with
vite().useCspNonce(), as the tags vite() writes do, so a strict Content Security Policy doesn't block it.
The welcome page, at /, follows the same cookie. Its styles are inline, with the nonce, and use plain custom
properties rather than CSS's light-dark(), so browsers without light-dark(), such as Safari before 17.5 and Chrome
before 123, get both themes too.
The stylesheet#
The kits with pages share resources/: Tailwind CSS 4 with the Oriel theme, one violet accent and system
fonts, in light and dark. Tailwind's dark: variant follows the dark class on <html>. The theme adds:
- Colours that follow the theme:
text-ink,text-body,text-muted,text-faint,border-line,text-accent,text-successandtext-danger(each with every utility Tailwind makes from a colour), and Oriel's fixed colours, such asbg-midnight-canvasandtext-glow-violet. - A type scale in place of Tailwind's:
text-small,text-sm,text-body,text-body-lg,text-intro,text-subheading,text-heading-sm,text-heading,text-heading-lgandtext-display. - Radii in place of Tailwind's:
rounded-control,rounded-list,rounded-glassandrounded-pill. Tailwind's shadow scales are removed: the component classes carry the depth.
And the component classes the pages are built from:
| Group | Classes |
|---|---|
| Buttons | btn (the primary), btn-secondary, btn-quiet, btn-danger, btn-ghost, btn-sm, btn-icon |
| Surfaces | card, card-danger, glass |
| Text | title, section-title, description, link, code |
| Forms | form, field, label, input, checkbox, error, status |
| Navigation | nav-link, seg, seg-icons, crumbs, menu, menu-button, menu-panel, menu-panel-start, menu-who, menu-item |
| Layout | shell, sidebar, side-nav, side-nav-end, shell-main, topbar, page, section, sky, sky-header, brand, avatar |
| Dashboard | progress, step, step-title, step-text, check, copy, copy-label, facts, healthy |
| Appearance | previews, preview, preview-swatch, preview-light, preview-dark, preview-system |
Utilities still override them: <button class="btn btn-sm w-full">.
Accessibility#
The kits are built to be read by everyone:
- Every text colour,
--faintincluded (placeholders, the breadcrumb's separator, finished checklist steps), keeps a contrast of 4.5:1 (WCAG AA) on the page, cards, the top bar, the sidebar and inputs, in both themes. So do the welcome page's footer and step numbers. .btn-danger, the Delete account button, is dark enough for its white label to keep 4.5:1, hovered or not..statusstays in the page even when it is empty, so a screen reader announces the status text that appears in it.- The Copy buttons announce "Copied" through a polite live region.
- The menus are native
<details>elements: Escape closes one and returns the focus to its button, and a click elsewhere, a choice inside it or the focus moving out of it closes it too. :focus-visibledraws a violet outline around the focused control.
Authentication#
The kits with pages register their routes in routes/ and routes/, and the dashboard and the
welcome page in routes/:
| URI | Method | Route name | What it does |
|---|---|---|---|
/ | GET | home | The welcome page |
/dashboard | GET | dashboard | The dashboard, for verified users |
/register | GET, POST | register | The registration page, and registering |
/login | GET, POST | login | The login page, and signing in |
/logout | POST | logout | Signing out |
/forgot-password | GET, POST | password.request, password.email | The reset link page, and sending the link |
/reset-password/ | GET | password.reset | The new password page |
/reset-password | POST | password.store | Setting the new password |
/verify-email | GET | verification.notice | The page that asks the user to verify their email |
/verify-email/ | GET | verification.verify | The signed link in the verification mail |
/email/ | POST | verification.send | Sending the verification mail again |
/settings | GET | A redirect to /settings/ | |
/settings/ | GET, PATCH, DELETE | profile.edit, profile.update, profile.destroy | The profile page, updating the name and email, and deleting the account |
/settings/ | GET, PUT | password.edit, password.update | The password page, and changing the password |
/settings/ | GET | appearance.edit | The appearance page |
Where a URI has two methods and one name, the name is the GET route's. The guest pages are behind the guest
middleware, and the rest behind auth. In the Live kit, a page's route is a GET route to its component, and the
forms are the component's actions: the POST, PATCH, PUT and DELETE routes, and logout and
verification.send, aren't there. node oriel route:list shows them all.
Email verification#
Users verify their email address before they reach the dashboard: its route has the verified middleware. A new
user gets the verification mail when they register, and an unverified user is sent to /verify-email, which can
send it again. A user who changes their email address on the profile page has to verify the new one.
To let users in without verifying, take verified off the dashboard's route:
import { Route, view } from '@orieljs/framework'import { DashboardController } from '../app/Http/Controllers/DashboardController'Route.get('/', () => view('welcome')).name('home')Route.middleware(['auth']).group(() => { Route.get('dashboard', DashboardController).name('dashboard')})In the Live kit, take it off the Route.view('dashboard', 'dashboard') route the same way. The verification mail is
still sent when a user registers.
Rate limiting#
The kits limit the routes an attacker would try many times:
| What | Limit |
|---|---|
| Signing in | 5 failed attempts per email address and IP address, then locked for a minute |
| The verification link, and sending it again | 6 requests a minute (throttle:6,1) |
| Changing the password | 6 requests a minute (throttle:6,1) |
| Sending a reset link | One link per email address a minute (passwords.users.throttle in config/) |
The login limit is in LoginRequest, in app/ (in the Live kit, in the Login component): change
the 5 in tooManyAttempts() to allow more. In the Live kit, sending the verification mail again and changing the
password are component actions, which have no limit of their own.
Customizing user creation#
Registration is RegisteredUserController's store() (in the Live kit, the Register component's register()).
It validates the form, creates the user, fires the Registered event, which sends the verification mail, and signs
the user in. To ask for more, add the field's rule and save it, after a migration that adds its column:
import { Auth, event, Hash, inertia, redirect, Registered, request, Rule } from '@orieljs/framework'import { User } from '../../../Models/User'import { Controller } from '../Controller'export class RegisteredUserController extends Controller { /** Show the registration page. */ create() { return inertia('Auth/Register') } /** Handle an incoming registration request. */ async store() { const data = await request().validate({ name: 'required|string|max:255', email: 'required|string|lowercase|email|max:255|unique:users', phone: 'required|string|max:20', password: ['required', 'confirmed', Rule.password()], }) const user = await User.create({ name: data.name, email: data.email, phone: data.phone, password: await Hash.make(data.password), }) await event(new Registered(user)) await Auth.login(user) return redirect().route('dashboard') }}Add phone to the model's fillable too, and the field to the registration page. Rule.password() asks for at
least 8 characters; Validating passwords shows how to ask for more.
The password reset is NewPasswordController (the ResetPassword component in Live), and the profile is
ProfileController and ProfileUpdateRequest (the Profile component). Deleting an account asks for the current
password, then signs the user out and deletes them.
The API kit#
The API kit's routes are in routes/, so their URIs start with /api:
| URI | Method | Route name | Token | Answer |
|---|---|---|---|---|
/api/ | POST | register | 201 with { user, token } | |
/api/ | POST | login | { token }; a 422 on bad credentials, locked for a minute after five failures | |
/api/ | GET | user | Yes | The user |
/api/ | POST | logout | Yes | 204; the token is revoked |
/api/ | POST | password.email | { status }, the same whether or not the account exists | |
/api/ | POST | password.store | { status }; every token of the user is revoked |
Send the token in the Authorization header: Authorization: Bearer <token>. The routes with a token are behind
auth:token, and the four without one are limited to 60 requests a minute per client (throttle:60,1). Errors are
JSON under /api, whatever the request's Accept header: bootstrap/ says so with shouldRenderJsonWhen().
The reset mail links to /reset-password/ on the host the request came to, which is your API's own
address. That page belongs to your client app, which posts the token, the email and the new password to
/api/, so point the link at it with ResetPassword.createUrlUsing() (see Reset link
customization).
The API kit has no email verification. To add it, add the verification routes and fire the Registered event in
RegisteredUserController, which then sends the mail.
Inertia SSR#
The React, Vue, Svelte and Solid kits render their pages on the server, so the first visit gets HTML. The Angular kit renders its pages in the browser only.
npm run build builds both bundles: the browser's into public/, and the server's, from resources/,
into bootstrap/. While npm run dev runs, Vite renders the pages on the server instead; right after it starts,
the first pages can come back rendered in the browser only while it warms up, and Vite prints "SSR skipped".
In production, run the SSR server next to your app:
node oriel inertia:start-ssrOn Bun, run bun oriel inertia:start-ssr --runtime=bun. The server listens on the port of INERTIA_SSR_URL,
http:// unless you set it, and INERTIA_SSR_ENABLED=false turns server-side rendering off. When no
SSR server answers, the pages are rendered in the browser, as in the Angular kit. inertia:check-ssr tells you
whether it is running, and inertia:stop-ssr stops it. Deployment shows how to keep it
running.
Frequently asked questions#
How do I upgrade?#
The kit's code is your app's from the start, so there is no kit to upgrade. The framework itself is a dependency:
update @orieljs/ and the other @orieljs/ packages with your package manager.
Where is the mail a new user receives?#
With the log mailer, a new app's default, in storage/, or storage/ with
MAIL_LOG_CHANNEL=mail in .env. The verification mail and the reset mail are Oriel's own notifications; the
Notifications page shows how to change them.