[{"data":1,"prerenderedAt":679},["ShallowReactive",2],{"navigation":3,"\u002Fblog\u002Fbluetooth-pos":48,"\u002Fblog\u002Fbluetooth-pos-surround":670,"$f7Td39Vonkf02bgvz57NR3EcUtcG7bggq1tBSUZMiX14":677},[4,23],{"title":5,"path":6,"stem":7,"children":8,"icon":22},"Getting Started","\u002Fdocs\u002Fgetting-started","1.docs\u002F1.getting-started\u002F1.index",[9,12,17],{"title":10,"path":6,"stem":7,"icon":11},"Introduction","i-lucide-house",{"title":13,"path":14,"stem":15,"icon":16},"Installation","\u002Fdocs\u002Fgetting-started\u002Finstallation","1.docs\u002F1.getting-started\u002F2.installation","i-lucide-download",{"title":18,"path":19,"stem":20,"icon":21},"Usage","\u002Fdocs\u002Fgetting-started\u002Fusage","1.docs\u002F1.getting-started\u002F3.usage","i-lucide-sliders",false,{"title":24,"path":25,"stem":26,"children":27,"page":22},"Essentials","\u002Fdocs\u002Fessentials","1.docs\u002F2.essentials",[28,33,38,43],{"title":29,"path":30,"stem":31,"icon":32},"Markdown Syntax","\u002Fdocs\u002Fessentials\u002Fmarkdown-syntax","1.docs\u002F2.essentials\u002F1.markdown-syntax","i-lucide-heading-1",{"title":34,"path":35,"stem":36,"icon":37},"Code Blocks","\u002Fdocs\u002Fessentials\u002Fcode-blocks","1.docs\u002F2.essentials\u002F2.code-blocks","i-lucide-code-xml",{"title":39,"path":40,"stem":41,"icon":42},"Prose Components","\u002Fdocs\u002Fessentials\u002Fprose-components","1.docs\u002F2.essentials\u002F3.prose-components","i-lucide-component",{"title":44,"path":45,"stem":46,"icon":47},"Images and Embeds","\u002Fdocs\u002Fessentials\u002Fimages-embeds","1.docs\u002F2.essentials\u002F4.images-embeds","i-lucide-image",{"id":49,"title":50,"authors":51,"badge":57,"body":59,"date":659,"description":660,"extension":661,"image":662,"meta":664,"navigation":665,"path":666,"seo":667,"stem":668,"__hash__":669},"posts\u002F3.blog\u002F1.bluetooth-pos.md","How I Architected a Modern POS System Using Laravel, Inertia.js, React, Midtrans, and Bluetooth Printing",[52],{"name":53,"to":54,"avatar":55},"Adi Sulaksono","https:\u002F\u002Fwww.github.com\u002Fadislksn\u002F",{"src":56},"\u002Fimg\u002Fprofile.png",{"label":58},"Laravel, React, POS",{"type":60,"value":61,"toc":645},"minimark",[62,66,90,101,106,109,137,140,171,175,178,236,290,345,348,352,358,372,430,437,441,452,455,472,516,520,523,553,556,560,563,589,593,596,599,628,631],[63,64,65],"p",{},"Building a point-of-sale system sounds straightforward until you actually ship one. Cashiers need speed. Owners need reports. Payments must be reliable. Receipts have to print instantly often over Bluetooth, on hardware that was never designed for the web.",[63,67,68,69,73,74,77,78,81,82,85,86,89],{},"This is how I architected a modern POS stack using ",[70,71,72],"strong",{},"Laravel"," on the backend, ",[70,75,76],{},"Inertia.js"," with ",[70,79,80],{},"React"," on the frontend, ",[70,83,84],{},"Midtrans"," for payments, and ",[70,87,88],{},"Bluetooth ESC\u002FPOS"," printing for thermal receipts.",[63,91,92],{},[93,94],"img",{"alt":95,"className":96,"height":98,"src":99,"width":100},"Modern point-of-sale terminal ready for checkout",[97],"rounded-lg",400,"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1742240216264-f0aac25ef4ba?w=1200&h=400&fit=crop",1200,[102,103,105],"h2",{"id":104},"why-this-stack","Why This Stack?",[63,107,108],{},"Most POS products are either legacy desktop apps or over engineered SaaS platforms. I wanted something in the middle:",[110,111,112,119,125,131],"ul",{},[113,114,115,118],"li",{},[70,116,117],{},"Fast UI"," for daily cashier workflows",[113,120,121,124],{},[70,122,123],{},"Single codebase"," for web admin and in-store checkout",[113,126,127,130],{},[70,128,129],{},"Indonesia ready payments"," via Midtrans",[113,132,133,136],{},[70,134,135],{},"Hardware support"," for Bluetooth thermal printers without native apps",[63,138,139],{},"Laravel + Inertia.js + React hit that balance. Laravel handles auth, inventory, orders, and webhooks. Inertia bridges server and client without a separate REST API layer for every screen. React delivers the interactive POS cart, product search, and checkout flow.",[141,142,143,153,162],"pictures",{},[144,145,146],"div",{},[63,147,148],{},[93,149],{"alt":150,"className":151,"height":98,"src":152,"width":98},"Developer workspace with laptop and code on screen",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1498050108023-c5249f4df085?w=400&h=400&fit=crop",[144,154,155],{},[63,156,157],{},[93,158],{"alt":159,"className":160,"height":98,"src":161,"width":98},"Payment terminal on a retail checkout counter",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1750262727446-759032bf283f?w=400&h=400&fit=crop",[144,163,164],{},[63,165,166],{},[93,167],{"alt":168,"className":169,"height":98,"src":170,"width":98},"Contactless card payment at point of sale",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1750263160599-53c7974bc79d?w=400&h=400&fit=crop",[102,172,174],{"id":173},"system-architecture","System Architecture",[63,176,177],{},"At a high level, the system splits into four layers:",[179,180,181,211],"tabs",{},[144,182,185],{"icon":183,"label":184},"i-lucide-layers","Layers",[110,186,187,193,199,205],{},[113,188,189,192],{},[70,190,191],{},"Presentation"," React pages rendered through Inertia.js (POS screen, product catalog, order history)",[113,194,195,198],{},[70,196,197],{},"Application"," Laravel controllers, form requests, policies, and service classes",[113,200,201,204],{},[70,202,203],{},"Domain"," Orders, line items, inventory movements, payment records, and receipt logs",[113,206,207,210],{},[70,208,209],{},"Integration"," Midtrans Snap\u002FCore API, Web Bluetooth ESC\u002FPOS, and optional queue workers",[144,212,215],{"icon":213,"label":214},"i-lucide-git-branch","Data Flow",[216,217,218,221,224,227,230,233],"ol",{},[113,219,220],{},"Cashier adds items to cart in React",[113,222,223],{},"Inertia posts checkout payload to Laravel",[113,225,226],{},"Laravel creates order + reserves stock inside a DB transaction",[113,228,229],{},"Midtrans payment token\u002Fcharge is generated when needed",[113,231,232],{},"Webhook confirms payment and marks order as paid",[113,234,235],{},"Frontend receives receipt payload and sends ESC\u002FPOS bytes to Bluetooth printer",[237,238,239,244,247,250,276,279],"picture-and-text",{},[240,241,243],"h3",{"id":242},"backend-laravel-as-the-source-of-truth","Backend: Laravel as the Source of Truth",[63,245,246],{},"Laravel owns every business rule. Products, categories, stock levels, discounts, tax, and user roles all live in the backend not in client-side state that can drift.",[63,248,249],{},"Key design choices:",[110,251,252,258,264,270],{},[113,253,254,257],{},[70,255,256],{},"Service classes"," for checkout, stock deduction, and payment reconciliation",[113,259,260,263],{},[70,261,262],{},"Database transactions"," so an order never commits without consistent inventory",[113,265,266,269],{},[70,267,268],{},"Policies"," to separate cashier, supervisor can, and owner permissions",[113,271,272,275],{},[70,273,274],{},"Webhook endpoint"," for Midtrans with idempotent payment status updates",[63,277,278],{},"The POS React app stays thin: it renders UI and sends validated payloads. Laravel decides what is allowed.",[280,281,283],"template",{"v-slot:image":282},"",[63,284,285],{},[93,286],{"alt":287,"className":288,"height":98,"src":289,"width":98},"Dashboard and analytics on a laptop screen",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1460925895917-afdab827c52f?w=400&h=400&fit=crop",[237,291,293,297,300,314,336],{":reverse":292},"true",[240,294,296],{"id":295},"frontend-inertiajs-react-for-the-pos-experience","Frontend: Inertia.js + React for the POS Experience",[63,298,299],{},"Inertia.js removed the need to build and maintain a separate REST API for every admin screen. Laravel returns page props; React renders them. For the POS floor, that meant:",[110,301,302,305,308,311],{},[113,303,304],{},"Instant product search and barcode friendly input",[113,306,307],{},"Cart state with quantity edits, notes, and discounts",[113,309,310],{},"Optimistic UI where safe, with server confirmation on checkout",[113,312,313],{},"Shared layouts for admin vs. cashier views",[63,315,316,317,321,322,321,325,321,328,331,332,335],{},"React components are organized by domain: ",[318,319,320],"code",{},"Cart",", ",[318,323,324],{},"ProductGrid",[318,326,327],{},"PaymentModal",[318,329,330],{},"ReceiptPreview",", and ",[318,333,334],{},"PrinterStatus",". Shared hooks handle keyboard shortcuts critical when cashiers process dozens of orders per hour.",[280,337,338],{"v-slot:image":282},[63,339,340],{},[93,341],{"alt":342,"className":343,"height":98,"src":344,"width":98},"Tablet-based point of sale on a wooden counter",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1762195954150-5db56b1d3a19?w=400&h=400&fit=crop",[63,346,347],{},"Decimal precision for money is handled server-side; the UI only displays formatted values from Laravel to avoid floating point bugs.",[102,349,351],{"id":350},"midtrans-payment-integration","Midtrans Payment Integration",[63,353,354,355,357],{},"For Indonesian merchants, ",[70,356,84],{}," is the practical choice. I integrated both:",[110,359,360,366],{},[113,361,362,365],{},[70,363,364],{},"Snap"," for hosted checkout flows when customers pay on a separate screen or link",[113,367,368,371],{},[70,369,370],{},"Core API"," for in-store scenarios where the POS triggers charge\u002Fstatus directly",[179,373,374,405],{},[144,375,378],{"icon":376,"label":377},"i-lucide-credit-card","Checkout Flow",[110,379,380,386,389,392,398],{},[113,381,382,383],{},"Create order in Laravel with status ",[318,384,385],{},"pending",[113,387,388],{},"Request Midtrans transaction token from backend (never expose server keys to React)",[113,390,391],{},"Open Snap modal or charge via Core API depending on payment method",[113,393,394,395],{},"Midtrans webhook hits ",[318,396,397],{},"\u002Fwebhooks\u002Fmidtrans",[113,399,400,401,404],{},"Laravel verifies signature, updates order to ",[318,402,403],{},"paid",", and emits receipt-ready event",[144,406,409],{"icon":407,"label":408},"i-lucide-shield-check","Reliability Rules",[110,410,411,421,424,427],{},[113,412,413,414,321,417,420],{},"Store ",[318,415,416],{},"order_id",[318,418,419],{},"transaction_id",", and raw webhook payload for audit",[113,422,423],{},"Treat webhooks as idempotent duplicate notifications must not double charge stock",[113,425,426],{},"Fail gracefully when payment expires; release reserved inventory",[113,428,429],{},"Log every state transition for end of day reconciliation",[63,431,432],{},[93,433],{"alt":434,"className":435,"height":98,"src":436,"width":100},"Payment terminal with card inserted at checkout",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1775750276384-123de61b4903?w=1200&h=400&fit=crop",[102,438,440],{"id":439},"bluetooth-thermal-printing-escpos","Bluetooth Thermal Printing (ESC\u002FPOS)",[63,442,443,444,447,448,451],{},"Printing was the hardest part of the stack. Most cheap thermal printers speak ",[70,445,446],{},"ESC\u002FPOS"," over Bluetooth Serial Port Profile (SPP). Browsers do not expose classic Bluetooth the way native apps do so I used the ",[70,449,450],{},"Web Bluetooth API"," where supported.",[63,453,454],{},"The flow:",[216,456,457,460,463,469],{},[113,458,459],{},"Laravel returns a normalized receipt DTO (store name, items, totals, payment method, timestamp)",[113,461,462],{},"A client-side encoder converts that DTO into ESC\u002FPOS byte commands",[113,464,465,468],{},[318,466,467],{},"navigator.bluetooth.requestDevice()"," pairs with the printer",[113,470,471],{},"Bytes stream to the printer characteristic; cashier gets paper in seconds",[179,473,474,498],{},[144,475,478],{"icon":476,"label":477},"i-lucide-printer","Receipt Contents",[110,479,480,483,486,489,492,495],{},[113,481,482],{},"Store header + address",[113,484,485],{},"Order number and cashier name",[113,487,488],{},"Line items with qty × price",[113,490,491],{},"Subtotal, discount, tax, grand total",[113,493,494],{},"Payment method (cash, QRIS, card via Midtrans)",[113,496,497],{},"Footer message and optional QR code",[144,499,502],{"icon":500,"label":501},"i-lucide-alert-triangle","Production Gotchas",[110,503,504,507,510,513],{},[113,505,506],{},"Web Bluetooth works best in Chromium browsers; always provide PDF\u002Femail fallback",[113,508,509],{},"Printer paper width (58mm vs 80mm) changes column layout encode both profiles",[113,511,512],{},"Reconnect logic matters; Bluetooth drops during busy hours",[113,514,515],{},"Test with real hardware early emulator bytes ≠ real printer behavior",[102,517,519],{"id":518},"database-inventory-model","Database & Inventory Model",[63,521,522],{},"A POS lives or dies on inventory accuracy. I modeled:",[110,524,525,531,541,547],{},[113,526,527,530],{},[70,528,529],{},"Products"," with SKU, price tiers, and active\u002Farchived state",[113,532,533,536,537,540],{},[70,534,535],{},"Stock movements"," as ledger entries (sale, restock, adjustment) instead of mutating a single ",[318,538,539],{},"qty"," column blindly",[113,542,543,546],{},[70,544,545],{},"Orders"," with polymorphic payment status and soft deletes for audit trails",[113,548,549,552],{},[70,550,551],{},"Shifts"," (optional) to tie cashiers to sessions and simplify daily closing reports",[63,554,555],{},"Every sale creates an order, order items, and outbound stock movements in one transaction. Refunds create inverse movements rather than deleting history.",[102,557,559],{"id":558},"what-i-would-do-differently","What I Would Do Differently",[63,561,562],{},"Shipping this taught me a few lessons worth sharing:",[110,564,565,571,577,583],{},[113,566,567,570],{},[70,568,569],{},"Start with printer compatibility tests"," before polishing the React UI",[113,572,573,576],{},[70,574,575],{},"Keep the POS offline tolerant"," cache product catalog locally for search when the network flickers",[113,578,579,582],{},[70,580,581],{},"Use queues"," for heavy report generation instead of blocking checkout",[113,584,585,588],{},[70,586,587],{},"Document Midtrans sandbox vs production"," config clearly; webhook URLs differ per environment",[102,590,592],{"id":591},"closing-thoughts","Closing Thoughts",[63,594,595],{},"A modern POS is not one feature it is orchestration. Laravel keeps business logic honest. Inertia.js and React make the cashier experience feel instant. Midtrans handles payments in a market specific way. Bluetooth printing closes the loop with a physical receipt customers expect.",[63,597,598],{},"If you are building something similar, optimize for the cashier first. Every extra click at checkout multiplies across thousands of transactions. Everything else reports, admin panels, integrations can iterate after the core loop is fast and reliable.",[141,600,601,610,619],{},[144,602,603],{},[63,604,605],{},[93,606],{"alt":607,"className":608,"height":98,"src":609,"width":98},"Card reader payment in a retail environment",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1556742049-0cfed4f6a45d?w=400&h=400&fit=crop",[144,611,612],{},[63,613,614],{},[93,615],{"alt":616,"className":617,"height":98,"src":618,"width":98},"POS system on counter with touchscreen interface",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1742240216264-f0aac25ef4ba?w=400&h=400&fit=crop",[144,620,621],{},[63,622,623],{},[93,624],{"alt":625,"className":626,"height":98,"src":627,"width":98},"Retail checkout with payment hardware",[97],"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1775750276384-123de61b4903?w=400&h=400&fit=crop",[629,630],"hr",{},[63,632,633],{},[634,635,636,637,644],"em",{},"Images from ",[638,639,643],"a",{"href":640,"rel":641},"https:\u002F\u002Funsplash.com",[642],"nofollow","Unsplash"," — free to use under the Unsplash License.",{"title":282,"searchDepth":646,"depth":646,"links":647},2,[648,649,654,655,656,657,658],{"id":104,"depth":646,"text":105},{"id":173,"depth":646,"text":174,"children":650},[651,653],{"id":242,"depth":652,"text":243},3,{"id":295,"depth":652,"text":296},{"id":350,"depth":646,"text":351},{"id":439,"depth":646,"text":440},{"id":518,"depth":646,"text":519},{"id":558,"depth":646,"text":559},{"id":591,"depth":646,"text":592},"2026-06-18","A full-stack walkthrough of building a production-ready point-of-sale from Laravel APIs and Inertia.js pages to Midtrans payments and ESC\u002FPOS Bluetooth receipt printing.","md",{"src":663},"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1742240216264-f0aac25ef4ba?w=640&h=360&fit=crop",{},true,"\u002Fblog\u002Fbluetooth-pos",{"title":50,"description":660},"3.blog\u002F1.bluetooth-pos","TjnnH0cfZJAgZibHeaO69F3mDlMYcNdjQtrMTw6uXRM",[671,672],null,{"title":673,"path":674,"stem":675,"description":676,"children":-1},"10 Things I Had to Unlearn When Moving from Developer to Project Manager","\u002Fblog\u002Fdev-to-manager","3.blog\u002F2.dev-to-manager","The mental shifts that matter most when you stop writing code full-time and start leading people, timelines, and trade-offs instead.",{"url":678},"https:\u002F\u002Fadislksn.web.id",1781800911550]