Progressive Web Apps have come a long way. At first, they were just “nice to have a simple way to cache assets and add a home screen icon. But nowadays, PWAs can really feel like native apps. They work offline, send push notifications, access device hardware, and offer smooth animations.
If you want to build something more than just a “basic PWA,” there’s one tool you should know: Capacitor.js. It lets you write your app in web tech (React, Vue, Angular, whatever you like), then wrap it as a native app with full access to native features without leaving your JavaScript comfort zone.
In this article, I’ll share some advanced tips and tricks for building next-level PWAs with Capacitor. Let’s get into it.
Why Use Capacitor.js for PWAs?
You might be familiar with Cordova or PhoneGap for wrapping web apps into native containers. Capacitor is kind of like their modern cousin — faster, simpler, and designed to work smoothly with today’s frameworks and build tools.
With Capacitor, you can:
- Deploy your app as a PWA on the web Package the exact same code as native apps for iOS and Android
- Use plugins to access native APIs like camera, GPS, notifications, and more
- Manage splash screens, app lifecycle events, and hardware back buttons easily
- This means you don’t have to choose between web and native — you get the best of both worlds.
Going Beyond Caching: Real Offline Support
A lot of PWAs just cache files and maybe some API responses to work offline. But what if your app needs complex offline capabilities? Like saving edits or tasks locally and syncing when the user gets back online?
That’s where IndexedDB comes in. It’s like a database right inside the browser. Using libraries like Dexie.js makes it easy to store and query data offline.
Here’s a simple example of storing and retrieving todos offline:
import Dexie from 'dexie';
interface Todo {
id?: number;
title: string;
done: boolean;
updatedAt: Date;
}
const db = new Dexie('TodoDB');
db.version(1).stores({
todos: '++id, title, done, updatedAt'
});
async function saveTodo(todo: Todo) {
todo.updatedAt = new Date();
await db.todos.put(todo);
}
async function getTodos() {
return db.todos.orderBy('updatedAt').reverse().toArray();
}
With this setup, users can create or edit tasks without an internet connection, and your app stays fast and reliable.
Syncing Data When Back Online
Offline storage is great, but it’s not enough if the app can’t sync changes to the server. For this, the Background Sync API helps — it tells the browser to hold off requests until a stable connection is available.
Here’s how you’d register a sync in your service worker:
self.addEventListener('sync', event => {
if (event.tag === 'sync-todos') {
event.waitUntil(syncTodos());
}
});
async function syncTodos() {
// Get unsynced todos from IndexedDB
const todos = await getUnsyncedTodos();
for (const todo of todos) {
await sendToServer(todo);
await markAsSynced(todo.id);
}
}
And in your app, you listen for connectivity changes using Capacitor’s Network plugin to trigger this sync:
import { Network } from '@capacitor/network';
Network.addListener('networkStatusChange', status => {
if (status.connected) {
navigator.serviceWorker.ready.then(sw => sw.sync.register('sync-todos'));
}
});
This means your app will automatically sync in the background, even if the user forgets to hit “sync” manually.
Native-Looking Splash Screens and Smooth Startup
Nobody likes staring at a blank white screen while the app loads. Capacitor helps here too, with native splash screens that show instantly while your app bundles load.
You can control this splash screen’s timing easily:
import { SplashScreen } from '@capacitor/splash-screen';
SplashScreen.show({ showDuration: 2500, autoHide: false });
setTimeout(() => {
SplashScreen.hide();
}, 2500);
This little detail makes your app feel polished and professional.
Push Notifications That Work Everywhere
Push notifications are tricky. The web push API works okay in browsers, but it can be inconsistent on mobile. Capacitor’s Push Notifications plugin lets you use native notifications on Android and iOS, while also supporting web.
Here’s how you can register for push notifications:
import { PushNotifications } from '@capacitor/push-notifications';
async function setupPush() {
const perm = await PushNotifications.requestPermissions();
if (perm.receive === 'granted') {
await PushNotifications.register();
}
}
PushNotifications.addListener('registration', token => {
console.log('Push token:', token.value);
// Send token to your backend
});
PushNotifications.addListener('pushNotificationReceived', notification => {
console.log('Notification received:', notification);
});
You get unified handling for push notifications, whether your users open your app from a browser or a native shell.
Accessing Hardware Features That Browsers Can’t Reach
Need to access the camera, high-accuracy GPS, or the file system? Capacitor plugins have you covered.
Example: taking a photo with native camera UI.
import { Camera, CameraResultType } from '@capacitor/camera';
async function snapPhoto() {
const photo = await Camera.getPhoto({
quality: 80,
allowEditing: true,
resultType: CameraResultType.Uri,
});
return photo.webPath;
}
This works consistently on native devices, and falls back gracefully to web APIs where needed.
Handling Gestures Like a Native App
Smooth gestures and touch interactions are key for great UX. Capacitor’s Gesture API lets you handle swipes, taps, and drags the same way on web and native.
Here’s a simple swipe handler in React:
import { Gesture } from '@capacitor/gesture';
import React, { useEffect, useRef } from 'react';
export function Swipeable({ onSwipeRight }: { onSwipeRight: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const gesture = Gesture.create({
el: ref.current,
gestureName: 'swipe',
onEnd: detail => {
if (detail.deltaX > 100) {
onSwipeRight();
}
},
});
gesture.enable(true);
return () => gesture.destroy();
}, [onSwipeRight]);
return <div ref={ref} style={{ touchAction: 'pan-y' }}>Swipe me</div>;
}
Making Your Code Play Nicely on Web & Native
Capacitor lets you detect if you’re running inside a native container:
import { Capacitor } from '@capacitor/core';
if (Capacitor.isNativePlatform()) {
// Call native-only APIs
} else {
// Web fallback logic
}
This means you can gracefully enable or disable features depending on the platform, keeping a single codebase clean and maintainable.
Wrapping Up
Building PWAs today is about much more than caching and manifests. To deliver top-notch, reliable, native-grade experiences, you need to combine:
- Robust offline data management
- Intelligent background syncing
- Native device integration with Capacitor
- Polished UX with splash screens and gestures
- Unified push notification handling
- Capacitor.js makes it surprisingly easy to do all this, letting you focus on your app instead of fighting platform quirks.
If you’re ready to build web apps that feel truly native, give Capacitor a spin — your users (and your sanity) will thank you.
Top comments (0)