# How to Add a Contactless Check-in System to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A contactless check-in system generates a unique QR code per registration, validates it server-side with idempotent logic (scan twice = still one check-in), and pushes live status to an admin dashboard via Supabase Realtime. With Lovable you can build a working event check-in in 4–8 hours for $0/month on the free tier. The two gotchas that sink first builds: camera access is blocked in preview iframes, and CSV export fails silently without a service_role key in the Edge Function.

## What a Contactless Check-in System Actually Is

A contactless check-in system replaces paper sign-in sheets with a QR code workflow: each attendee receives a unique QR code by email after registering; they show it at the venue; a staff member scans it with a phone or a self-service kiosk reads it automatically. The system validates the token, marks the attendee as checked in, and updates the admin dashboard in real time. The three product decisions that define the build are how tokens are generated (UUID per registration is the standard), where validation logic lives (always server-side — client-side validation can be forged), and how the admin dashboard updates (Supabase Realtime is the right answer, not polling). Works for events, clinics, offices, co-working spaces, and hotels.

## Anatomy of the Feature

Seven components. Lovable handles the QR generation, token store, and real-time dashboard well on the first prompt. The idempotency check in the Edge Function and the CSV export service role key are the two places builds silently break.

- **QR code generator** (service): qrcode.react (web) or qr_flutter (Flutter) renders a QR code client-side encoding the full check-in URL: https://app.com/check-in/{token}. The token is a UUID generated when the registration row is created in Supabase — gen_random_uuid() at INSERT time. No API call is needed; QR generation is entirely browser-side.
- **Token validation endpoint** (backend): A Supabase Edge Function (Deno) that receives a check-in token, checks the registrations table for the token, verifies whether checked_in is already true, and either marks it true with checked_in_at = now() or returns a status indicating the reason. Returns one of three JSON responses: {status: 'success', attendee_name}, {status: 'already_checked_in', checked_in_at}, {status: 'invalid_token'}. The SELECT-before-UPDATE pattern ensures idempotency.
- **Real-time admin dashboard** (ui): A protected /admin/dashboard page with a Supabase Realtime channel subscription on the registrations table. Shows live counters: Total Registered, Checked In, Pending, No-Show (after event end time). A shadcn/ui Table lists all attendees with name, email, checked-in status, and timestamp. The Realtime subscription uses supabase.channel('check-ins').on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'registrations' }, handleUpdate).subscribe() with an empty dependency array so it stays active for the entire session.
- **Registration and token store** (data): Supabase registrations table stores one row per attendee per event. The check_in_token is a UUID generated at INSERT time (gen_random_uuid() default). checked_in is a boolean defaulting to false; checked_in_at is a nullable timestamptz. The token column has a UNIQUE constraint to prevent collisions and to enable fast O(1) lookup.
- **Camera QR scanner (admin scan mode)** (service): html5-qrcode library (web) or mobile_scanner Flutter package (mobile) allows staff to scan guest QR codes using the device's camera. The decoded URL is passed to the token validation Edge Function. Camera access requires HTTPS and does not work inside cross-origin iframes — testing must be done on the published app URL.
- **Email with QR code** (service): A Supabase Edge Function triggered after a successful registration INSERT sends the attendee their QR code via Resend. The email uses a React Email template with the QR code rendered as a base64 PNG inline image (generated server-side using the qrcode npm package in Deno). The email includes event name, date, venue, and the QR code image with instructions to 'show this at the door'.
- **CSV export** (backend): A Supabase Edge Function that reads all registrations for a given event_id and returns them as a CSV file with columns: attendee_name, attendee_email, checked_in (true/false), checked_in_at. Triggered by a button in the admin dashboard. The function must use the Supabase service_role key to bypass RLS, because the event owner's session JWT is not available in the Edge Function context without explicit forwarding.

## Data model

Two tables: events (optional, for multi-event support) and registrations (the core). Run this in the Supabase SQL editor — it includes the unique token constraint and the index needed for fast scan lookups.

```sql
create table public.events (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  event_date timestamptz,
  location text,
  created_by uuid references auth.users(id) on delete cascade not null,
  created_at timestamptz not null default now()
);

create table public.registrations (
  id uuid primary key default gen_random_uuid(),
  event_id uuid references public.events(id) on delete cascade not null,
  attendee_name text not null,
  attendee_email text not null,
  check_in_token uuid not null unique default gen_random_uuid(),
  checked_in bool not null default false,
  checked_in_at timestamptz,
  created_at timestamptz not null default now()
);

-- Enable RLS
alter table public.events enable row level security;
alter table public.registrations enable row level security;

-- Event owners can manage their events
create policy "Event owners can manage events"
  on public.events for all
  using (auth.uid() = created_by)
  with check (auth.uid() = created_by);

-- Anyone can self-register (INSERT) for an event
create policy "Anyone can register for an event"
  on public.registrations for insert
  with check (true);

-- Only the event owner can SELECT or UPDATE registrations
create policy "Event owners can view registrations"
  on public.registrations for select
  using (
    exists (
      select 1 from public.events
      where events.id = registrations.event_id
      and events.created_by = auth.uid()
    )
  );

create policy "Event owners can update check-in status"
  on public.registrations for update
  using (
    exists (
      select 1 from public.events
      where events.id = registrations.event_id
      and events.created_by = auth.uid()
    )
  );

-- Critical: index for fast token lookup on every scan
create index registrations_token_idx on public.registrations (check_in_token);
create index registrations_event_idx on public.registrations (event_id, checked_in);
create index events_created_by_idx on public.events (created_by);
```

The unique constraint on check_in_token prevents token collisions. The index on check_in_token makes every scan validation O(log n) instead of a full table scan. Enable Supabase Realtime for the registrations table in Supabase Dashboard → Database → Replication → select the registrations table — this is not automatic.

## Build paths

### Lovable — fit 4/10, 4-6 hours

Best path for this feature — Lovable handles Supabase Realtime well and can wire the token validation Edge Function, QR code display, and real-time dashboard in one session. Camera scanning in the admin mode must be tested on the published URL, never in the preview iframe.

1. Create a new Lovable project and connect Lovable Cloud to provision Supabase auth and the events + registrations tables automatically
2. Paste the prompt below into Agent Mode to build the registration form, QR code display, check-in page, and admin dashboard
3. Open Supabase Dashboard → Database → Replication → enable Realtime for the registrations table so the live dashboard receives updates
4. Open Lovable Cloud tab → Secrets, add RESEND_API_KEY for QR code delivery emails
5. Run the SQL schema from this page in the Supabase SQL editor to add the token index and the event-owner-scoped RLS policies
6. Click Publish and test camera scanning on the live HTTPS URL — open the /admin/scan page on your phone, grant camera permission, and scan a test QR code to verify the three response states

Starter prompt:

```
Build a contactless check-in system. Supabase tables: events (id, name, event_date, location, created_by referencing auth.users), registrations (id, event_id, attendee_name, attendee_email, check_in_token uuid unique default gen_random_uuid(), checked_in bool default false, checked_in_at timestamptz). Registration page at /register/[event_id]: form with attendee_name and attendee_email, on submit inserts a registration row, then triggers a Supabase Edge Function that sends a QR code email via Resend. QR code display page at /my-ticket/[registration_id]: shows qrcode.react QR code encoding the full URL https://[production_domain]/check-in/[check_in_token] — read production domain from environment variable VITE_APP_URL. Check-in validation page at /check-in/[token]: calls a Supabase Edge Function that does SELECT checked_in FROM registrations WHERE check_in_token = token — if not found: {status: 'invalid_token'}; if checked_in = true: {status: 'already_checked_in', checked_in_at}; else UPDATE checked_in=true, checked_in_at=now(), return {status: 'success', attendee_name}. Render three UI states: green success (attendee name + checkmark), amber already-checked-in (shows time of first check-in), red invalid token. Kiosk mode: auto-reset to a neutral scan-ready screen 3 seconds after any result. Admin dashboard at /admin/[event_id] (protected by auth check against events.created_by): Supabase Realtime subscription on registrations table with empty dependency array; live counters for Total, Checked In, Pending; shadcn/ui Table listing all attendees with status and checked_in_at; CSV export button that calls a Supabase Edge Function using the service_role key. Admin scan page at /admin/scan: html5-qrcode camera scanner that reads a QR code and POSTs the decoded token to the check-in Edge Function, displaying the result inline.
```

Limitations:

- The html5-qrcode camera scanner does not activate in the Lovable preview iframe — test all camera functionality on the published HTTPS URL only
- The CSV export Edge Function must use the SUPABASE_SERVICE_ROLE_KEY env var to bypass RLS — using the anon key returns zero rows silently
- Supabase Realtime must be manually enabled for the registrations table in the Supabase Dashboard — it is not automatically enabled when Lovable creates the table

### V0 — fit 3/10, 5-8 hours

Strong for the admin dashboard UI with Next.js Server Components and shadcn/ui Table. Camera scanning (html5-qrcode) requires browser camera permissions that do not work in the V0 preview sandbox — all camera testing must happen on the deployed Vercel URL.

1. Prompt V0 with the spec for the admin dashboard, check-in page, and token validation API route
2. In the V0 Vars panel, add NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, and RESEND_API_KEY
3. Run the SQL schema from this page in the Supabase SQL editor and enable Realtime for the registrations table
4. Deploy to Vercel before testing any camera functionality — the V0 sandbox blocks getUserMedia entirely
5. Verify the token validation API route performs SELECT-before-UPDATE and returns the correct status strings for all three check-in outcomes

Starter prompt:

```
Build a contactless event check-in system in Next.js App Router with shadcn/ui and Supabase. API route at /api/check-in: accepts POST with body {token: string}; uses Supabase service_role client; SELECT id, attendee_name, checked_in, checked_in_at FROM registrations WHERE check_in_token = token; if no row: return {status: 'invalid_token'}; if checked_in: return {status: 'already_checked_in', checked_in_at}; else UPDATE checked_in=true, checked_in_at=now() and return {status: 'success', attendee_name}. API route at /api/export/[event_id]: uses service_role client to SELECT all registrations for the event, returns CSV with headers name,email,checked_in,checked_in_at; event auth check: verify events.created_by = session user_id before export. Check-in page at /check-in/[token]: client component that auto-calls /api/check-in on mount using the token from the URL param; renders three states: success (green card with attendee name), already_checked_in (amber card with original check-in time), invalid_token (red card). Auto-reset in kiosk mode: useEffect with 3-second timeout to redirect back to /scan or clear the result state. Admin dashboard at /admin/[event_id] (middleware auth check): Supabase Realtime subscription in a client component with useEffect(() => { const channel = supabase.channel('check-ins').on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'registrations', filter: 'event_id=eq.' + eventId }, handleUpdate).subscribe(); return () => { supabase.removeChannel(channel); }; }, []); live stat cards (Total, Checked In, Pending); shadcn/ui DataTable with columns: name, email, status badge, checked_in_at; Export CSV button calls /api/export/[event_id] and triggers file download via Blob URL. QR display at /ticket/[id]: qrcode.react encoding process.env.NEXT_PUBLIC_APP_URL + '/check-in/' + check_in_token.
```

Limitations:

- Camera access for the html5-qrcode scanner is blocked in the V0 preview sandbox — deploy to Vercel before testing any scan functionality
- Supabase Realtime requires the registrations table to have replication enabled in Supabase Dashboard → Database → Replication — this is not automatic
- The service_role key must never be used in client-side code — always route export and check-in operations through Next.js API routes where it is server-side only

### Flutterflow — fit 4/10, 1-2 days

Excellent for a native kiosk or tablet app — mobile_scanner works perfectly on a real device and is faster and more reliable for repeated scanning than web-based camera APIs. Firebase Realtime Database or Supabase provides live check-in counts. The token validation logic must run in a backend function called via REST.

1. Add the mobile_scanner Flutter package as a Custom Widget in FlutterFlow for the QR scanning interface
2. Create a Custom Action that makes a POST request to the token validation Supabase Edge Function URL and stores the response status in page state
3. Build the check-in result screen with three conditional visibility states: success (green background), already_checked_in (amber), invalid_token (red) — all driven by the response status page state variable
4. Add a Timer Custom Action in the kiosk success state that resets the page state to scan-ready after 3 seconds
5. Wire Firebase Realtime Database or Supabase Realtime for the admin counter screen to show live check-in counts without polling
6. Enable camera permission in Settings → App Settings → Permissions and add NSCameraUsageDescription for iOS App Store compliance

Limitations:

- The token validation SELECT-before-UPDATE logic must run in a Supabase Edge Function or Firebase Cloud Function — FlutterFlow Action Flows cannot perform transactional DB operations
- CSV export requires calling a backend Edge Function via a Custom HTTP Action — FlutterFlow cannot generate and download a file natively without custom code
- The FlutterFlow web preview cannot access the camera — test on a real device via the FlutterFlow mobile preview app

### Custom — fit 5/10, 1-2 weeks

Required for NFC wristband or badge scanning, integration with ticketing platforms (Eventbrite, Ticket Tailor), multi-session conference check-in, or feeding check-in data into a CRM in real time.

1. Next.js PWA deployed to Vercel — the check-in scan page works offline using a service worker that queues scans and syncs when connectivity returns
2. NFC wristband support via the Web NFC API (Chrome on Android) or a hardware NFC reader connected to the kiosk tablet
3. Eventbrite webhook receiver: Supabase Edge Function listens for order.placed events, inserts registrations, and sends QR code emails automatically
4. CRM sync: check-in events trigger a Supabase Database Webhook that posts to a Zapier or n8n webhook, creating a 'Checked In' activity in Salesforce or HubSpot

Limitations:

- NFC via the Web NFC API is Chrome on Android only — iOS does not support Web NFC; iOS NFC requires a native app wrapper

## Gotchas

- **QR camera scanner does not activate — blank screen where the video should be** — The browser Geolocation API and getUserMedia (camera) are blocked in cross-origin iframes unless the iframe explicitly sets allow='camera'. Both Lovable's preview iframe and V0's sandbox iframe block camera access by default. This means the html5-qrcode scanner loads, shows its UI, but the video never starts — no error is thrown, just a black or blank container. Developers often spend an hour debugging a permissions issue that does not actually exist in the real app. Fix: Do not test camera scanning inside any preview environment. Click Publish in Lovable (or deploy to Vercel from V0) and open the admin scan page on your phone over the published HTTPS URL. Camera access will work correctly on the published app. Add a visible 'Open in new tab' link in the scanner UI to make it easy for staff to leave the preview.
- **Scanning the same QR code twice checks the attendee in a second time** — AI tools commonly generate the check-in validation as a simple UPDATE: UPDATE registrations SET checked_in = true WHERE check_in_token = token. Without first checking whether checked_in is already true, this runs successfully on every scan and always returns a success response. A slow-moving crowd with a staff member accidentally scanning the same QR twice creates duplicate check-in records and incorrect counters. Fix: In the Edge Function, always SELECT first: const { data: reg } = await supabase.from('registrations').select('id, checked_in, checked_in_at, attendee_name').eq('check_in_token', token).single(). If reg.checked_in is true, return {status: 'already_checked_in', checked_in_at: reg.checked_in_at} without running the UPDATE. Only run the UPDATE when checked_in is false.
- **Real-time dashboard doesn't update when check-ins happen** — Two separate root causes: (1) Supabase Realtime is not enabled for the registrations table — it requires explicit opt-in in Supabase Dashboard → Database → Replication, and creating the table in Lovable or via SQL does not enable it automatically. (2) The Realtime channel subscription is set up inside a useEffect with dependencies that cause it to be created and immediately cleaned up, so no updates are ever received. Fix: In Supabase Dashboard, go to Database → Replication and toggle the registrations table to include it in the Realtime publication. In the React component, set up the subscription with an empty dependency array: useEffect(() => { const channel = supabase.channel('check-ins').on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'registrations' }, handleUpdate).subscribe(); return () => { supabase.removeChannel(channel); }; }, []). The empty array ensures the subscription lives for the full component lifetime.
- **CSV export downloads an empty file** — The CSV export Edge Function queries the registrations table using the anon Supabase client key. The event-owner-scoped RLS policy (restricting SELECT to the event creator) requires an authenticated JWT to work correctly. An Edge Function called without forwarding the user's auth header runs as the anon role, which the RLS policy blocks — resulting in an empty query result and a CSV with only the header row. Fix: In the CSV export Edge Function, use the service_role key: const supabase = createClient(Deno.env.get('SUPABASE_URL'), Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')). Store the service_role key in Lovable Secrets or Vercel env vars, never in code. Add a manual authorization check inside the function to verify the requesting user's JWT claims that they own the event before running the query.

## Best practices

- Always run token validation in a server-side Supabase Edge Function — client-side validation can be bypassed by any attendee who inspects the network request
- Implement SELECT-before-UPDATE idempotency in the check-in Edge Function so duplicate scans of the same QR code return 'already_checked_in' instead of counting twice
- Enable Supabase Realtime for the registrations table explicitly in the Dashboard — it is not enabled automatically and the admin dashboard will never update without it
- Use gen_random_uuid() as the default for check_in_token at INSERT time rather than generating the UUID in application code — database-generated tokens are cryptographically random and never duplicated
- Use the SUPABASE_SERVICE_ROLE_KEY in admin-only Edge Functions (CSV export, bulk check-in) and never in client-side code or in functions accessible without authentication
- Store the production app URL in an environment variable and read it at QR code render time — never derive it from window.location.href which will encode the preview domain in printed QR codes
- Show all three check-in states (success, already_checked_in, invalid_token) with distinct colors and clear copy — 'Already Checked In at 9:47 AM' is more useful to staff than a generic error
- Implement kiosk auto-reset with a 3-second timeout after any check-in result so the screen is ready for the next attendee without requiring staff interaction

## Frequently asked questions

### Does the attendee need to download an app to check in?

No. The attendee receives a QR code by email and simply shows it at the venue. The staff-facing scanner runs in the browser on any phone over HTTPS — no app download required on either side. For a fully self-service kiosk, the attendee scans their own code at a tablet running the check-in page in browser kiosk mode. FlutterFlow builds a native iOS/Android kiosk app if you need a more locked-down experience for high-volume events.

### How do I prevent someone from sharing their QR code and multiple people checking in with it?

The server-side idempotency check handles this. The second person who scans the same QR code receives the 'Already Checked In at [time]' response — the system does not check them in. For high-security events where you need to catch this in real time, add a photo of the registrant to the success response so staff can visually verify the person matches. The check_in_token is single-use by design: once checked_in = true, no further UPDATE runs on that row.

### Can I use the same system for checking in to multiple sessions at a conference?

With the schema as designed, each registration is per-event. For multi-session check-in, extend the schema: add a sessions table (id, event_id, name, start_time), a session_registrations table linking registrations to sessions, and a session_check_ins table recording the per-session scan. Each session gets its own QR code or the same attendee QR code is checked against a session-specific validation endpoint. This extension adds about 4–6 hours to the build.

### What happens if a guest's phone battery dies and they can't show the QR code?

Build a manual override in the admin dashboard: a search field that looks up attendees by name or email, and a 'Check In Manually' button that calls the same validation Edge Function with the check_in_token retrieved from the DB. This gives staff a backup that does not bypass the idempotency check — manually checking in an already-checked-in attendee still returns the 'Already Checked In' response.

### How do I send check-in QR codes to attendees after they register?

A Supabase Edge Function triggered by the registrations INSERT sends an email via Resend with the QR code as an inline base64 PNG generated server-side using the qrcode npm package (available in Deno). The email template (built with React Email) includes the event name, date, venue, and clear instructions to show the QR at the door. Set up the Edge Function as a Database Webhook trigger on the registrations table for the INSERT event so it fires automatically on every new registration.

### Can staff scan QR codes with their phone camera or do they need a special scanner?

Staff use any phone running the /admin/scan page in their browser — html5-qrcode activates the device camera and decodes QR codes in real time without additional hardware. For high-volume events (hundreds of check-ins in minutes), a handheld Bluetooth HID barcode scanner reads faster and more reliably than a phone camera in bright sunlight. Bluetooth HID scanners work with the web app out of the box — they send keystrokes to the active input field, and the check-in page can listen for that input pattern.

### How do I export the check-in data for compliance or reporting?

The CSV export button in the admin dashboard calls a Supabase Edge Function that reads all registrations for the event using the service_role key (to bypass RLS), formats them as CSV with columns for name, email, checked_in status, and check-in timestamp, and returns the response with Content-Type: text/csv and Content-Disposition: attachment headers. The browser triggers a file download automatically. The same function can be extended to return XLSX format using a Deno-compatible spreadsheet library if your event clients need Excel format.

### Can I set up a self-service kiosk at the venue entrance?

Yes. The check-in page at /check-in/[token] is designed to be the kiosk screen. Point a tablet at the check-in page in browser kiosk mode (Full Screen + Home button disabled). Attendees scan their own QR code using the admin scanner page, or the kiosk displays an html5-qrcode reader fullscreen. After 3 seconds on the success or error screen, the page auto-resets. For a fully locked-down kiosk, use a dedicated Android tablet with a kiosk browser app (Fully Kiosk Browser) that prevents attendees from leaving the check-in page.

---

Source: https://www.rapidevelopers.com/app-features/contactless-check-in-system
© RapidDev — https://www.rapidevelopers.com/app-features/contactless-check-in-system
