Building a Real App with Nitro (Part 2): Persistence, Validation, and Production Readiness
By Vantol Bennett
Author
Introduction
In Part 1, we built the foundation of BrewStop, a food truck / coffee shop backend powered by Nitro. We created menu endpoints, business hours, and a basic order flow.
In Part 2, we’ll turn that prototype into something production-ready by adding:
- Input validation
- Persistent storage (using UnJS tools)
- Order status updates
- Middleware & plugins
- Better structure for deployment
This is where Nitro really shines — small additions, big capability.
Main Content
Recap: Where We Left Off#
At the end of Part 1, we had:
GET /api/menuGET /api/hoursPOST /api/orders- In-memory order storage
The biggest limitation? Orders disappear on restart and inputs are not validated. Let’s fix that.
Step 1: Add Persistent Storage with Unstorage#
Nitro integrates beautifully with Unstorage, a key-value storage system from the UnJS ecosystem.
Install Unstorage#
npm install unstorage
Create a Storage Utility#
Create server/utils/storage.ts:
import { createStorage } from "unstorage"
import fsDriver from "unstorage/drivers/fs"
export const storage = createStorage({
driver: fsDriver({ base: "./.data" })
})
This gives us:
- Persistent storage
- Zero database setup
- Easy swap to Redis, Cloudflare KV, etc.
You can switch storage drivers without rewriting your application logic. ::
Step 2: Refactor Orders to Use Storage#
Update server/api/orders.post.ts:
import { storage } from "../utils/storage"
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const order = {
id: crypto.randomUUID(),
items: body.items,
status: "pending",
createdAt: Date.now()
}
await storage.setItem(`orders:${order.id}`, order)
return order
})
Now orders persist across restarts.
Step 3: Input Validation#
Never trust user input — even for coffee ☕.
Create server/utils/validation.ts:
export function validateOrder(body: any) {
if (!body?.items || !Array.isArray(body.items)) {
throw createError({
statusCode: 400,
statusMessage: "Invalid order format"
})
}
}
Use it in your endpoint:
import { validateOrder } from "../utils/validation"
export default defineEventHandler(async (event) => {
const body = await readBody(event)
validateOrder(body)
// continue logic
})
Validation protects your API from malformed requests and accidental misuse. ::
Step 4: Fetch Orders & Update Status#
Get All Orders#
Create server/api/orders.get.ts:
import { storage } from "../utils/storage"
export default defineEventHandler(async () => {
const keys = await storage.getKeys("orders:")
const orders = await Promise.all(
keys.map((key) => storage.getItem(key))
)
return orders
})
Update Order Status#
Create server/api/orders/[id].patch.ts:
import { storage } from "../../utils/storage"
export default defineEventHandler(async (event) => {
const id = event.context.params?.id
const body = await readBody(event)
const order = await storage.getItem(`orders:${id}`)
if (!order) {
throw createError({ statusCode: 404, statusMessage: "Order not found" })
}
const updated = { ...order, status: body.status }
await storage.setItem(`orders:${id}`, updated)
return updated
})
Now BrewStop supports a real workflow:
- pending → preparing → ready → completed
Step 5: Add Middleware (Logging)#
Create server/plugins/logger.ts:
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("request", (event) => {
console.log(
`[${new Date().toISOString()}] ${event.method} ${event.path}`
)
})
})
This gives you centralized request logging without cluttering endpoints.
Step 6: Simple API Key Protection#
Let’s protect order updates.
Update nitro.config.ts:
export default defineNitroConfig({
runtimeConfig: {
apiKey: "super-secret-key"
}
})
Create server/plugins/auth.ts:
export default defineNitroPlugin(() => {
addEventHandler((event) => {
if (event.path.startsWith("/api/orders") && event.method !== "GET") {
const key = event.headers.get("x-api-key")
if (key !== useRuntimeConfig().apiKey) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" })
}
}
})
})
This is intentionally simple — Nitro plugins scale well to JWT, OAuth, or third-party auth later. ::
Step 7: Deployment Readiness#
Build your app:
npm run build
Nitro outputs a .output directory that can run on:
- Node.js servers
- Serverless platforms
- Edge runtimes
node .output/server/index.mjs
No changes needed.
Final API Overview#
BrewStop API (Final)#
- GET
/api/menu - GET
/api/hours - GET
/api/orders - POST
/api/orders - PATCH
/api/orders/:id::
This backend can now realistically power:
- A POS system
- A mobile app
- A Nuxt frontend
- An admin dashboard
Conclusion
In just two parts, we’ve built a real backend using Nitro:
- Persistent storage
- Validation
- Middleware
- Secure endpoints
- Multi-runtime deployment
Nitro proves that you don’t need heavy frameworks or complex infrastructure to build serious applications. With composable UnJS tools and clean architecture, you can ship fast — and scale later.
Next steps?
- Add a frontend (Nuxt or Vite)
- Add payments
- Deploy to edge
- Replace storage with a database
BrewStop is production-ready — and this is only the beginning. ☕🚀
Share this post
Stay Updated
Get the latest posts delivered straight to your inbox.
